diff --git a/docs/index.md b/docs/index.md index dc0fb8f..b8ce78e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- layout: default title: AI PatchLab Scans -description: "101 curated security scans of open-source AI agents, MCP servers and LLM apps - 23 confirmed fixes, run local-first with Semgrep, Gitleaks, Trivy and pip-audit." +description: "102 curated security scans of open-source AI agents, MCP servers and LLM apps - 23 confirmed fixes, run local-first with Semgrep, Gitleaks, Trivy and pip-audit." --- # AI PatchLab Scans @@ -20,7 +20,7 @@ remediation and confidence rules to normalize the findings. > **Want this run privately against your own codebase?** I do independent > security review of AI agents, MCP servers, and LLM apps — -> [**work with me →**]({{ '/work-with-me' | relative_url }}). 101 scans, 23 confirmed fixes, methodology in the open. +> [**work with me →**]({{ '/work-with-me' | relative_url }}). 102 scans, 23 confirmed fixes, methodology in the open. > **OpenAI just launched [Daybreak](https://openai.com/index/daybreak-securing-the-world/) and Patch the Planet.** > Same remediation loop, opposite trade-off: their path is a cloud frontier model; @@ -94,12 +94,13 @@ login and static assets. Fifty-two flagged, none reported. ## All scans -101 scans, newest first. **Findings** is the raw count the tools produced; +102 scans, newest first. **Findings** is the raw count the tools produced; **Real** is what survived curation. The gap between those two columns is the entire job. | Date | Repository | Findings | Real | Outcome | | --- | --- | ---: | --- | --- | +| 2026-09-12 | [langroid/langroid](scans/langroid-langroid.html) | 132 | 0 real | — | | 2026-09-11 | [mljar/mercury](scans/mljar-mercury.html) | 140 | 1 real — withheld | private | | 2026-09-10 | [Datus-ai/Datus-agent](scans/datus-ai-datus-agent.html) | 291 | 1 real | open | | 2026-09-09 | [mims-harvard/ToolUniverse](scans/mims-harvard-tooluniverse.html) | 284 | 1 real — withheld | private | diff --git a/docs/scan-log.md b/docs/scan-log.md index bbf0e6e..a6139da 100644 --- a/docs/scan-log.md +++ b/docs/scan-log.md @@ -6,8 +6,9 @@ description: "The complete AI PatchLab scan log: every public repository scanned # Full scan log -Every scan in the series, newest first, with the summary written on the day of the scan. 101 scans. For the compact index, see the [scan log home]({{ '/' | relative_url }}). +Every scan in the series, newest first, with the summary written on the day of the scan. 102 scans. For the compact index, see the [scan log home]({{ '/' | relative_url }}). +- **2026-09-12** — [langroid/langroid](scans/langroid-langroid.html) — 132 findings, **0 real** — **a multi-agent LLM programming framework** (4.1k★, MIT, 459 Python files, three distinct authors merging in the last 60 days). **Thirty-third clean scan**, and the one where the target set the exam. Langroid ships a `SECURITY.md` that does the rare thing: it names its own `sanitize_command()` and SQL/Cypher denylists as **best-effort hardening, not security boundaries** — "they exist to stop an *unlucky* LLM, not a *determined attacker*" — points at the real boundaries (database role, container, egress), and then **enumerates seven in-scope categories**. That converts "is this safe?" into checkable propositions, so the scan checked them: path traversal in the file tools and in repo ingestion; XXE and deserialization in the message parsers; credential leakage in a default config; **a *missing call* to a documented gate**; `allowed_statement_types` misclassification; dependency CVEs with demonstrated impact. **Seven for seven.** The [advertised-boundary test](scans/mims-harvard-tooluniverse.html) is only worth running because it can come back *no* — this series has published it [failing](scans/mljar-mercury.html) — and publishing the pass is what makes those readable. The methodological find is a sweep that **over-reports**: three advisories (seed CVE-2026-54771, then `RecipientTool`, then `RewindTool`) share one mechanism — relabel USER content as `sender=Entity.LLM` and the taint filter stops applying — and each advisory blames a handler having no `chat_doc` parameter. Sweep on that and 16 of 24 handlers look unguarded, including `SendTool`/`AgentSendTool`, which are *fine*: there are **three** correct taint idioms (doc-derived via `chat_doc`, the per-tool `self._tainted` mark, and implicit propagation through the `tool_messages` list). The [inverse of the under-reporting sweep](scans/mai-with-u-maibot.html) — on default-deny code, a single-idiom sweep's false positives look exactly like the two real CVEs. **Enumerate the idioms before counting violations.** The dependency half is the cleanest two-install-path demonstration in the series: pip-audit read the `pyproject.toml` floors and saw **nltk 3.10.3** (1 advisory); Trivy read `uv.lock` and saw **nltk 3.10.0** (17). Same package, same scan, both right — and a report that averaged them would have destroyed the only signal. The lock governs CI and contributors (`uv sync --dev`); the Docker image and every PyPI consumer resolve from the floors (`uv pip install .`), so the 83 lockfile advisories touch no shipped artifact. Three criticals died on reachability, each differently: `unstructured`'s SSRF names `partition(url=)` and langroid only calls `partition_pdf`/`_docx`/`_doc`; `chainlit`'s RCE needs `features.mcp.enabled`, which the shipped config leaves off; `chromadb` is **ceiling-pinned `<=0.4.23` with no fixed version at all**, but both its CVEs describe the ChromaDB *server* and langroid embeds the client. Published in full instead of filed: `uv.lock` pins `gitpython` 3.1.56 against **CVE-2026-78676, a critical config-injection RCE fixed in 3.1.59** — and langroid imports gitpython in its own `file_tools.py` and `repo_loader.py`, so it is reachable code, just not in any consumer's environment. There is no `.github/dependabot.yml`, which is the [lockfile-coverage gap](scans/whiteguo233-openbiliclaw.html) in its quietest form: nothing is configured to notice. Credit where due — the one novel High (`${{ inputs.pytest_args }}` into a `run:` block) sits under a maintainer comment correctly reasoning that `workflow_dispatch` is write-access-only. - **2026-09-11** — [mljar/mercury](scans/mljar-mercury.html) — 140 findings, **1 real — withheld** — **turn a Jupyter notebook into a public web app** (4.4k★, Apache-2.0, MLJAR, 8 human contributors, issues closed the day of the scan). Reported privately via **GitHub PVR** ([GHSA-8hq5-94w5-f4ff](https://github.com/mljar/mercury/security/advisories/GHSA-8hq5-94w5-f4ff), triage): PVR enabled is [signalled consent for a private channel](scans/hkuds-openopc.html) that [outranks the absent SECURITY.md](scans/mims-harvard-tooluniverse.html), and the finding is a *working, unauthenticated* request against the boundary the product advertises — a public issue would be a live-deployment recipe. The class is an **[intra-repo guard differential](scans/mims-harvard-tooluniverse.html)**, **invisible to all four tools**: Mercury's standalone default disables the Jupyter token (it is meant to be published *publicly*, "simple and safe") and ships an execution **firewall** that blocks the dangerous authenticated endpoints so anonymous visitors can only do safe things. Two sibling handlers list notebooks from the same configured directory; one **trusts a caller-supplied path** to pick which directory, the other ignores it — and in the token-less default the trusting one is anonymous. Impact is **bounded and the bound was verified**: it escapes the configured directory to enumerate notebooks elsewhere on the host and read their title/description metadata, and doubles as a directory-existence oracle — but **not** file content: the read/render path goes through Jupyter's contents manager, which correctly rejects `..` (404), and the listing code reads only metadata. The *how* is the point — **the firewall is its own specification**: a deny-list is a list of endpoints the maintainers already know are anonymously reachable and dangerous, so `@authenticated` provably does not gate this mode; [enumerate every handler, subtract the blocked and the replaced](scans/whiteguo233-openbiliclaw.html), and read what remains against its safe sibling. Reproduced against the shipped **mercury 3.2.11** with a positive control (served notebook still lists), a negative control (traversal read still 404s), and the firewall's own 403s as a third. **The scary tiers are the docs website**: the single `Critical` (Astro AVIF RCE) is in `docs/package-lock.json`, and most of the 66 High / 71 Medium are npm advisories in `yarn.lock` and `docs/` — Mercury ships as a **Python package**, so none reach a `pip install` user; [reachability-blind SCA scoring the repo, not the artifact](scans/ascending-llc-jarvis-registry.html). Curation's first act was separating the shipped wheel from the co-located docs site. **The defenses a scanner cannot credit**: the execution path never runs client-supplied code (the browser sends a cell *id*, the server resolves source from its own manifest and rejects stale revisions), the contents manager confines traversal, notebook rendering is sanitized, and the firewall blocks terminals/contents/shutdown/files/Lab. SHA-1 in `manager.py` is a truncated config-cache key over `_safe_str`-sanitized input (non-crypto FP); the Jinja autoescape hit is operator config, not attacker input. Semgrep clean (14 results, 2 errors, 375 scanned, 0 skipped) · reported privately, post-only with detail withheld pending maintainer response - **2026-09-10** — [Datus-ai/Datus-agent](scans/datus-ai-datus-agent.html) — 291 findings, **1 real** — a **natural-language-to-SQL data-analysis agent** (1.7k★, Apache-2.0, commercial — "Copyright 2025-present DatusAI, Inc." on every file, 5 human contributors merging in 60 days). The class is the [inert/legacy path a careful codebase left behind](scans/sentelabsai-openexecutive.html), and the finding is **invisible to all four tools**. Datus runs **two auth systems in one FastAPI app**: a clean new `AuthProvider`/`AppContext` layer whose default trusts headers *by documented design* (a [downward boundary](scans/tracecathq-tracecat.html) — the deployment is expected to front it), and a **legacy** JWT surface (`/auth/token` → `/workflows/run` + `/workflows/feedback`) that actively *claims* to authenticate and is the one the API docs present as the way in. That legacy path ships a **hardcoded default signing secret** (`"your-secret-key-change-in-production"`, `legacy_auth.py:23`) and **default client credentials** (`datus_client`/`datus_secret_key`, `:20`) that `load_auth_config` returns **silently whenever `~/.datus/conf/auth_clients.yml` is absent** (`:62`) — and only `auth_clients.yml.example` is committed, so absence is the default state of a fresh install, with no warning and no refusal to boot. The server binds `0.0.0.0:8000` by default, so a reachable attacker gets **two** ways in, the second needing no credential: authenticate with the published default, or **forge** a JWT signed with the published constant — the project's own `validate_token` (`:103`) accepts it, and either token drives `service.run_workflow` → `Agent` → SQL execution. **CWE-321**: a hardcoded key's secrecy *is* the security property, so "change in production" advice does not help against anyone reading the public source; the fix is to **fail closed** on the default (as the project's own bash sandbox already does), not to document it harder. Verified with a **differential** built from the module imported verbatim, at a nonexistent config path (fresh install), with a **negative control** (a wrong-key token is rejected, proving the check runs). **The scary tiers are noise**: the 3 `Critical` LiteLLM CVEs are **Proxy-only** against a client-library import (`litellm_adapter.py`) — [version-match → reachable?](scans/ascending-llc-jarvis-registry.html) fails at "reachable"; the 79 raw/formatted-SQL hits are the **engine layer of a NL→SQL tool** doing its job, interpolating identifiers and config values with the real guard being the advertised policy/auto-review layer — the [parameterized-SQL identifier FP](scans/mims-harvard-tooluniverse.html), **eleventh** appearance; 57 GHA hardening rows; every Gitleaks hit a docs `curl` example, `sample_data/`, or a test fixture. **The defenses a scanner cannot credit are the rest of the story**: CORS disables credentials exactly on wildcard origin (`allow_credentials=cors_origins != ["*"]` — the gate this series has *asked five projects to add*), `deps.get_scoped_sub_agent` refuses an unknown sub-agent with 400 rather than building an unscoped read, the bash tool refuses to run when its bwrap/Seatbelt sandbox is unavailable, and DuckDB runs with `enable_external_access=false`. No `SECURITY.md` (root, `.github/`, docs site all 404), PVR **off**, no org email — but the vulnerable constant is *already public in the repo*, so a public issue leaks nothing not already committed · one focused public issue filed ([#1415](https://github.com/Datus-ai/Datus-agent/issues/1415)), leading with the code path and a fail-closed fix, PR offered - **2026-09-09** — [mims-harvard/ToolUniverse](scans/mims-harvard-tooluniverse.html) — 284 findings, **1 real — withheld** — a **tool-calling platform for "AI scientists"** (1.7k★, Apache-2.0, Harvard MIMS lab, ~600 wrapped scientific and biomedical APIs, 10 distinct PR authors in 60 days). The class is an **intra-repo guard differential**: a genuinely well-reasoned shared security module documents two independent controls for the project’s network servers — an opt-in bearer token and a request-time Host/Origin check — and its own docstring says the second is what protects a loopback bind from DNS rebinding *when no token is set*, which is the shipped default. Every network server it fronts is consistent except two, which install one control and not the other. **No rule found this** — it came from reading the module’s docstring as a specification and tabulating which implementations satisfy it, the [contract-versus-artifact](scans/ginlix-ai-langalpha.html) move again. Reported with a runnable differential built from the project’s own helpers imported verbatim, with a positive control (the legitimate local client still gets `200`, so the fix breaks nothing) and a negative control (the project’s **own sibling surface** rejects the identical request with `421`). **The two dependency tools disagreed and both were right**: pip-audit read `pyproject.toml` and reported **0** across 156 packages, Trivy read `uv.lock` and reported **97** — open floors describe the user install (`uv pip install`), the lockfile describes the *contributor* install the developer guide prescribes (`uv sync`), and both paths are real and documented. My own `dependency-scan-unaudited-lockfile` meta-finding flagged the gap before I noticed it — twice in three days now. **82 Gitleaks hits, 0 secrets**: a repo wrapping 600 scientific databases is *made* of high-entropy strings (accession IDs like `4DNEXHVF8WA9`, dataset UUIDs), and the one real hardcoded key is the vendor’s **documented public demo key**, with the signup URL on the line above — the clearest case yet that [finding count scales with surface richness, not risk](scans/whiteguo233-openbiliclaw.html). The `exec`/`pickle`/dynamic-import tier is **product surface** for a tool platform, and the one endpoint that deserializes a caller-supplied pickle path *is* behind the guard; all four workflow shell-injection hits need write access already (`workflow_dispatch`/tag-push/schedule, no `pull_request_target` anywhere); the `render_template_string` hit is a static literal; and the [parameterized-SQL identifier FP](scans/doobidoo-mcp-memory-service.html) took its **tenth** appearance. On a codebase this careful the generic checklist is exhausted immediately — what is left is finding where the project is inconsistent **with itself**. PVR enabled → private route (`GHSA-mv53-jxjr-hp8g`, in triage) · reported privately, post-only @@ -54,7 +55,7 @@ Every scan in the series, newest first, with the summary written on the day of t - **2026-07-27** — [dimensionalOS/dimos](scans/dimensionalos-dimos.html) — 280 findings, **0 real** — **25th clean scan**, and the first **agentic OS for physical robots** in the series: dimOS (~3.8k★, Apache-2.0, *Dimensional Inc.*) drives humanoids, quadrupeds, drones, and manipulators in natural language, wired to real cameras/lidar/actuators. When your code moves an arm in a room with people the question shifts from *can an attacker read a file?* to *can an attacker move the arm?* — and it closes **secure-by-default**. The natural-language command server (`POST /submit_query`, `POST /unitree/command`) is **unauthenticated** — the same class as the resolved [code-graph-rag #808](scans/vitali87-code-graph-rag.html) — but every listener (web, MCP Streamable-HTTP, visualizer) resolves its bind through one field, `global_config.listen_host`, whose **default is `127.0.0.1`**; the scattered `0.0.0.0` literals are exactly the surfaces that *need* LAN reach (phone/Quest teleop, drone MAVLink, gstreamer video). The lone **Critical** — a chromadb **pre-auth server RCE** (CVE-2026-45829) — is **not reachable**: `spatial_vector_db.py` calls **`chromadb.Client()`** embedded in-process, so the vulnerable `/api/v2/…collections` endpoint is never served ([version-match ≠ reachable](scans/maziyarpanahi-openmed.html)). The 7 `defused-xml` "XXE" highs are stdlib `etree` over **local robot-description files** (URDF/MJCF/Drake world) → DoS-only billion-laughs, **not** XXE ([KiCAD-MCP](scans/mixelpixx-kicad-mcp-server.html) class) — the one concrete code change (swap `defusedxml`). The `eval` is an **operator-local ROS-topic CLI**; the two `shell=True` run a **developer-set** native-build command; the `tarfile.extractall` expands the project's **own git-LFS** data archives; `transformers` `trust_remote_code=True` defaults to **pinned trusted VL models** (Florence-2/Moondream, [mitigation-aware](scans/maziyarpanahi-openmed.html)). The 21-finding SQL cluster is the [#1 parameterized-**identifier** FP](scans/mnemosyne-oss-mnemosyne.html) — data bound `?`, only internal table names interpolated, **guarded by `validate_identifier`**. The one genuinely not-well-built bit is `wildcard-cors` + `allow_credentials=True` on the loopback dev interface (inverse of credit-the-defense, bounded impact). Deps refresh across **two lockfiles** (`uv.lock` + `native/rust/Cargo.lock`), reachability-gated (Pillow via vision = DoS, native Rust `lz4_flex`/PyO3, LangSmith transitive). A **safety-mature** project — ships an `AI_POLICY.md` ("code moves real hardware… safety-critical real-world environments," mandatory sim/replay for motion changes) + CLA. Commercial-backed · post-only - **2026-07-26** — [CodeGraphContext/CodeGraphContext](scans/codegraphcontext-codegraphcontext.html) — 112 findings, **0 real** — **24th clean scan**, and one of the more *security-aware* codebases in the series: an **MCP server + CLI** (4.0k★, MIT) that indexes a local codebase into a graph DB (Neo4j / embedded KùzuDB / FalkorDB) so an AI assistant can ask structural questions over a real code graph. Rich surface — it reads arbitrary source off disk, builds graph queries, and ships an **optional HTTP/SSE gateway** for ChatGPT Actions + remote agents, a VSCode extension, and a website. The three questions — *is the graph-query path injectable?*, *is the gateway an unauth exposure?*, *are DB creds + secrets safe?* — all close, because the maintainer writes the mitigation next to the risk. The **12 graph-DDL "SQL highs"** are the purest [#1 identifier FP](scans/mnemosyne-oss-mnemosyne.html) yet: all `CREATE`/`ALTER TABLE` schema statements whose only interpolated tokens come from **hardcoded constant tuples** (no data value exists in DDL). The `wildcard-cors` on the gateway is paired with `allow_credentials=False` **and a comment naming the trap** (credit-the-defense); Neo4j has **no default password** (`getenv('NEO4J_PASSWORD')`, fails closed); the 3 gitleaks JWTs are Supabase **anon keys** (public-by-design frontend, [IBM ContextForge](scans/ibm-mcp-context-forge.html) class) + a CI service password + generated `scip_pb2.py` protobuf bytes. The gateway's `require_api_key` is a real **router-wide** FastAPI dependency (constant-time compare, opt-in) that logs a **loud unauth warning naming the exact Cypher/tool exposure**. The one genuine item is a **secure-by-default** nit — `cgc api start` defaults `--host 0.0.0.0` while auth is opt-in (same class as the resolved [code-graph-rag #808](scans/vitali87-code-graph-rag.html)) — but the auth mechanism, the warning, and a documented `--host 127.0.0.1` already pre-mitigate it. The one concrete code change: swap stdlib `ElementTree` for `defusedxml` in the Maven/MyBatis indexers (DoS-only etree, [KiCAD-MCP](scans/mixelpixx-kicad-mcp-server.html) class). Residual = a `website/` frontend dep refresh (lodash/PostCSS/`ws`/react-router) + transitive `protobuf` bump. Strict-norm (`.github/SECURITY.md`, email-only private reporting) · post-only - **2026-07-25** — [Osmantic/ODS](scans/osmantic-ods.html) — 73 findings, **0 real** — **23rd clean scan**, and one of the richer appliance surfaces in the series: the **Osmantic Deployment System** (3.6k★, Apache-2.0) turns a box into a private, self-hosted **AI server** — local inference, a chat UI, a control dashboard, voice/agents/workflows, RAG, image gen — orchestrated over Docker (Ollama, Open WebUI, n8n, ComfyUI). For an appliance that manages Docker, secrets, and network exposure the questions are *is the dashboard reverse proxy an SSRF/host-spoof pivot?*, *are service creds handled + logged safely?*, and *is the token-store SQL injectable?* — all three close, because ODS already did the work. The 5 `nginx` `dynamic-proxy-host` "hits" are `proxy_pass` to a **constant** internal upstream (`set $dashboard_api_upstream dashboard-api:3002`) forced through Docker DNS re-resolution, with a **Bearer header injected** on every location (the `B1`/`B2` fixes marked right in the config); the 7 `logger-credential-leak` mediums log the generated key's **file path** (`secrets.token_urlsafe(32)` + `chmod(0o600)`), never its value (à la [linkedin-mcp](scans/stickerdaniel-linkedin-mcp-server.html)/[google-workspace](scans/taylorwilsdon-google-workspace-mcp.html)); the 5 SQL "highs" are the [#1 parameterized-**identifier** FP](scans/mnemosyne-oss-mnemosyne.html), and exemplary — bound `?` values, a `_RECENT_TS_BOUND` constant, and a `SAFE_IDENTIFIER` regex on a *hardcoded* column name "to protect against future refactoring." The 4 `insecure-websocket` are 2 **scheme-preserving** `http→ws`/`https→wss` upgrades + 2 non-code text matches (a `requirements.txt` comment, the audit doc itself); the 5 `run-shell-injection` are non-privileged `pull_request` (interpolating the maintainer-controlled `base_ref`) + `workflow_dispatch` triggers ([openmed](scans/maziyarpanahi-openmed.html) lesson). Residuals are all operator-gated and already in the project's own threat model: a react-router dashboard dep refresh (localhost-bound + Bearer-gated), root-user model-serving Dockerfiles, SHA-pin action tags. This is a **security-mature project scanned, not audited** — it ships a private-reporting `SECURITY.md`, a `SECURITY_AUDIT.md` receipts file, and an operator-hardening guide. Strict-norm (private reporting + commercial backing at osmantic.com) · post-only -- **2026-07-24** — [gpustack/gpustack](scans/gpustack-gpustack.html) — 136 findings, **0 real** — **22nd clean scan**: a **GPU cluster manager for AI model serving** (5.4k★, Apache-2.0, 97% Python) that registers worker nodes, proxies OpenAI-compatible inference to vLLM/SGLang, and offers SSH-accessible GPU instances. For a serving control-plane the questions are *is the model-proxy an open SSRF pivot?*, *is the worker tunnel authenticated?*, and *is API-key handling sound?* — all three close. The proxy (`routes/worker/proxy.py`) is `Depends(worker_auth)`-gated and builds its upstream URL from **cluster state** (`worker_ip_getter()` + a gateway-set routing header), not a user-supplied host, so the `tainted-url-host` flag is intra-cluster routing, not egress (guarded, à la [IBM ContextForge](scans/ibm-mcp-context-forge.html)). `security.py` runs on **argon2 + blake2b + `secrets`**; the flagged "generic-api-key" is **docstring example values**. The `0o700`+`secrets.token_hex` randomized operator unix socket trips `insecure-file-permissions` but is *exactly right* (the [linkedin-mcp](scans/stickerdaniel-linkedin-mcp-server.html) active-harm-FP), and `message_client.py` **upgrades** `http→ws`/`https→wss` rather than forcing plaintext. The count is structural: **46 SQL "highs" are all Alembic migration/enum DDL** — the [#1 identifier FP](scans/mnemosyne-oss-mnemosyne.html), third scan where migrations dominate ([codex-lb](scans/openai-codex.html) lesson) — plus 29 GHA `mutable-ref` pins and 15 test-fixture "secrets". The lone **Critical** (`asyncmy` SQLi CVE-2025-65896) is doubly gated: MySQL-backend-only (default is SQLite) *and* the crafted-dict-key primitive isn't exposed through SQLModel/SQLAlchemy — [version-match ≠ reachable](scans/maziyarpanahi-openmed.html). Residual = a reachability-gated dep refresh (`asyncmy`/`pyasn1`/`setuptools`); all 17 gitleaks hits FP (tests/docs/docstring). Not strict-norm (no SECURITY.md) · post-only +- **2026-07-24** — [gpustack/gpustack](scans/gpustack-gpustack.html) — 136 findings, **0 real** — **22nd clean scan**: a **GPU cluster manager for AI model serving** (5.4k★, Apache-2.0, 97% Python) that registers worker nodes, proxies OpenAI-compatible inference to vLLM/SGLang, and offers SSH-accessible GPU instances. For a serving control-plane the questions are *is the model-proxy an open SSRF pivot?*, *is the worker tunnel authenticated?*, and *is API-key handling sound?* — all three close. The proxy (`routes/worker/proxy.py`) is `Depends(worker_auth)`-gated and builds its upstream URL from **cluster state** (`worker_ip_getter()` + a gateway-set routing header), not a user-supplied host, so the `tainted-url-host` flag is intra-cluster routing, not egress (guarded, à la [IBM ContextForge](scans/ibm-mcp-context-forge.html)). `security.py` runs on **argon2 + blake2b + `secrets`**; the flagged "generic-api-key" is **docstring example values**. The `0o700`+`secrets.token_hex` randomized operator unix socket trips `insecure-file-permissions` but is *exactly right* (the [linkedin-mcp](scans/stickerdaniel-linkedin-mcp-server.html) active-harm-FP), and `message_client.py` **upgrades** `http→ws`/`https→wss` rather than forcing plaintext. The count is structural: **46 SQL "highs" are all Alembic migration/enum DDL** — the [#1 identifier FP](scans/mnemosyne-oss-mnemosyne.html), third scan where migrations dominate ([codex-lb](scans/soju06-codex-lb.html) lesson) — plus 29 GHA `mutable-ref` pins and 15 test-fixture "secrets". The lone **Critical** (`asyncmy` SQLi CVE-2025-65896) is doubly gated: MySQL-backend-only (default is SQLite) *and* the crafted-dict-key primitive isn't exposed through SQLModel/SQLAlchemy — [version-match ≠ reachable](scans/maziyarpanahi-openmed.html). Residual = a reachability-gated dep refresh (`asyncmy`/`pyasn1`/`setuptools`); all 17 gitleaks hits FP (tests/docs/docstring). Not strict-norm (no SECURITY.md) · post-only - **2026-07-23** — [EverMind-AI/Raven](scans/evermind-ai-raven.html) — 87 findings, **0 real** — **21st clean scan**: a memory-first, self-improving **agent harness** (2.5k★, Apache-2.0, MiroThinker deep research) — ~73% Python, with a gateway that runs agent turns, shell-exec + web-fetch tools, an "evolver" that mutates and re-benchmarks itself, and a TUI. For an agent harness with filesystem/exec tools the two questions are *can an untrusted party drive it over the network?* and *is web-fetch an open SSRF?* — and both close well. The **3 MCP SDK "highs"** are **server-transport** CVEs, but `raven/agent/tools/mcp.py` is a *client* (outbound connections only) → not reachable, the inverse of [code-graph-rag](scans/vitali87-code-graph-rag.html) where `serve_http()` *was* stood up. The web-fetch surface is **defended**: a dedicated `raven/security/network.py` blocks private/**metadata** CIDRs (`169.254/16`) and is wired into `web_fetch` *before* the fetch; the DingTalk adapter re-validates **every redirect hop** (`follow_redirects=False`) — closing the redirect-to-internal gap most guards miss (defended, à la [IBM ContextForge](scans/ibm-mcp-context-forge.html), vs undefended [optillm](scans/algorithmicsuperintelligence-optillm.html)). The `GatewayConfig.host = "0.0.0.0"` semgrep flagged is a **defined-but-unused** default — the only listener is a `/health` endpoint pinned to `127.0.0.1`, TUI↔gateway rides local FIFOs. The shipped `eval()` is a **parameter name** (a harness-eval callable, not the builtin); the `tarfile.extractall` is a **benchmark** expanding its own self-written checkpoints; the 2 `run-shell-injection` are **privileged** tag-push/`workflow_dispatch` triggers ([openmed](scans/maziyarpanahi-openmed.html) lesson); the 14 JS path-traversal are a **localhost tracing viewer**; `non-literal-import`×5 are plugin registries; `sha1`×3 are cache keys. Residual = a dep refresh (Pillow multimodal, mistune via the opt-in Matrix channel) — all reachability-gated. Strict-norm (SECURITY.md → private advisory reporting) · post-only - **2026-07-22** — [mixelpixx/KiCAD-MCP-Server](scans/mixelpixx-kicad-mcp-server.html) — 29 findings, **0 real** — **20th clean scan**: an MCP server (1.6k★, MIT) that drives **KiCAD** for AI-assisted PCB design — a TypeScript protocol server that spawns a long-lived Python `pcbnew` backend, **STDIO transport only**. For an MCP server the question is whether a tool argument reaches a dangerous sink, and every candidate closes: the **freerouting** autorouter shells out to `java -jar` but builds an **argv `List[str]`** (no `shell=True`, AI-supplied paths are argv elements — *credit the defense*, à la [potpie](scans/potpie-ai-potpie.html)); the datasheet helper **constructs** an LCSC URL and never fetches it (no SSRF, the [tradingview-mcp](scans/atilaahmettaner-tradingview-mcp.html) constant-authority pattern); `express` is imported but never `listen`s → **no network surface** to protect. The 4 JLCPCB SQL "highs" are the [#1 recurring parameterized-**identifier** FP](scans/mnemosyne-oss-mnemosyne.html) (`f"…FROM [{relation}]"` from the source DB's own schema, values bound `?`); the 2 `child_process` highs are **operator-gated** (`pythonExe` from `KICAD_PYTHON` env, and the real `spawn` path already uses argv — an `execFile` nit); the 4 path-traversal are constant segments on a `__dirname` root. The one genuine residual is **DoS-only**: 6 stdlib `ElementTree` import parsers with no `defusedxml` — but CPython's etree **doesn't resolve external entities**, so it's billion-laughs entity-expansion (local, self-inflicted on importing a malicious design file), **not** XXE file-read/SSRF. Gitleaks/Trivy/pip-audit all clean. Not strict-norm · post-only - **2026-07-21** — [ucbepic/docetl](scans/ucbepic-docetl.html) — 124 findings, **1 real — and no tool ranked it** — an agentic map-reduce / document-ETL engine (3.9k★, UC Berkeley EPIC) with a FastAPI backend and a Next.js DocWrangler UI. The `exec`/`eval` the scanner headlines are the **documented code-operator model** (author writes a `transform`; the engine runs it — by-design, à la [ag2](scans/ag2ai-ag2.html) / [datachain](scans/datachain-ai-datachain.html)), so the real finding is the mundane plumbing bolted beside it: the `/fs` router (`read-file`/`read-file-page`/`check-file`/`write-pipeline-config`/`save_workspace`) takes a **raw client-supplied `path`/`namespace` with no confinement and no auth** → unauthenticated **arbitrary file read *and* write** (plus a fetch-and-store **SSRF** in `upload-file?url=`, `follow_redirects=True`, no allowlist). The tell: `serve-document` one function up *does* carry an `if ".." in path` guard — the pattern is understood, just not applied. Reachability is the whole story and it cuts both ways: bare-metal binds `localhost` and CORS defaults tight (**both credited**), but the shipped `docker-compose.yml` binds `BACKEND_HOST=0.0.0.0` with **zero auth in the server**, the pipeline route drives `DSLRunner` from a request-supplied `yaml_config` (code-operators = the ceiling above the file-read floor), and the `docetl-aws` profile mounts `~/.aws:ro` → an unauth file read is a cloud-cred read. Meanwhile **Semgrep's 14 traversal hits all landed in the TS frontend**, not this Python sink (scanner under-count, the [zotero-mcp](scans/54yyyu-zotero-mcp.html) sweep lesson), and the report's "critical" was a low-reachability `nltk` Zip-Slip. Deps: 3 crit across `uv.lock` + `package-lock.json`, no Dependabot ([Kiln](scans/kiln-ai-kiln.html) coverage gap) → **filed [#505](https://github.com/ucbepic/docetl/issues/505)**. Not strict-norm · post + focused issue diff --git a/docs/scans/langroid-langroid.md b/docs/scans/langroid-langroid.md new file mode 100644 index 0000000..b9004f0 --- /dev/null +++ b/docs/scans/langroid-langroid.md @@ -0,0 +1,174 @@ +--- +layout: default +title: "langroid/langroid: security scan" +description: "Security scan of langroid/langroid, a multi-agent LLM programming framework: 132 findings, zero real, thirty-third clean scan. The repository ships a SECURITY.md that names its own boundaries and declares what is out of scope — this scan tested the document against the code, in-scope item by in-scope item, and it held." +date: 2026-09-12 +--- + +# langroid/langroid — security scan + +**Repository:** [langroid/langroid](https://github.com/langroid/langroid) — 4.1k★, MIT, a multi-agent LLM programming framework: agents that chat, call tools, query SQL and graph databases, run pandas expressions, ingest repositories and documents, and route messages between each other. 459 Python files. Actively maintained — three distinct authors merged PRs in the last 60 days and issues are closed within days. +**Commit scanned:** `053dbfe` (HEAD of `main` at scan time — pushed the morning of the scan) +**Scan date:** 2026-09-12 +**Disclosure status:** Nothing to disclose. Strict-norm repository (substantial `SECURITY.md`, private vulnerability reporting enabled, 16 published advisories) — the quality gate was not met, so nothing was filed, publicly or privately. Everything below is published in full. + +## Summary + +| Severity | Count (medium+) | +| --- | ---: | +| Critical | 6 | +| High | 51 | +| Medium | 70 | +| Low | 0 | +| Info | 3 (scanner meta) | + +**Total findings:** 132 (130 above the `medium` floor) — **zero real after curation. Thirty-third clean scan.** + +Coverage note: Semgrep scanned 585 files, skipped 0, and reported **3 errors** — all `PartialParsing`, on `Dockerfile` and `.github/workflows/docker-publish.yml`. Those two files were hand-reviewed below, because a parse error means the rules did not run and their absence from the results is not evidence of anything. Gitleaks returned zero. Trivy read `uv.lock` (83 advisories); pip-audit resolved the `pyproject.toml` floors (181 packages, 1 advisory). The two dependency tools disagree by a factor of 83, and that disagreement is the most useful thing in the report — see below. + +## Why this target + +Most repositories in this series have no security policy, or a copy of GitHub's default. Langroid has something rarer: a `SECURITY.md` that states a threat model, names which of its own functions are **not** security boundaries, lists what is in scope, and pre-declares what will be closed without a fix. + +It says, in effect: *this framework's purpose is executing model-generated code and queries against your data. That is the feature. An LLM is not a trust boundary.* It then names its own `sanitize_command()`, its SQL/Cypher/AQL denylists and its AST checks as **best-effort hardening, not security boundaries** — "they exist to stop an *unlucky* LLM, not a *determined attacker*" — and points at the real boundaries: the database role you hand the agent, the container it runs in, the egress rules. + +That is an *advertised boundary*, and this series has a standing test for one: does the code match the document? A declaration of scope is only worth anything if the things declared **in** scope are actually defended. The policy makes that testable, because it enumerates them: + +> - Trust boundary, message routing, sender verification. +> - Parsing and deserialization flaws (XXE, unsafe deserialization, decompression bombs) in the document and message parsers. +> - Path traversal in the file tools (`ReadFileTool`, `WriteFileTool`, `ListDirTool`) and in repository / folder ingestion. +> - Credential or secret leakage in a default configuration. +> - **A code path that skips a documented safety gate entirely** — that is, a *missing call* to a validator, not a *bypass* of one. +> - **A statement the `allowed_statement_types` allowlist misclassifies.** +> - Vulnerable dependencies with a demonstrated impact on Langroid. + +So this scan is not a hunt for a denylist bypass — the maintainer has already said those get closed, and he is right to: a denylist over four SQL dialects cannot be completed. It is a check of the seven claims above, one at a time. The interesting property of that framing is that it can come back **no**. It came back no. That is the point of running it. + +## What the 132 findings actually were + +- **83 Trivy advisories, every one of them from `uv.lock`** — and none from the `Dockerfile` target. Discussed in its own section below, because the version numbers are the story. +- **27 `github-actions-mutable-action-tag`** hits — `actions/checkout@v4`, `docker/login-action@v3`, and friends, pinned by tag rather than SHA. Supply-chain hygiene worth doing; not a vulnerability, and the same 27 would fire on most repositories on GitHub. +- **8 SQL and eval hits** — 4 `avoid-sqlalchemy-text`, 2 `sqlalchemy-execute-raw-query`, 2 `formatted-sql-query`, plus 3 `eval-detected`. Every one lands on `SQLChatAgent` or the pandas paths. These are the *product*. A framework whose documented purpose is running LLM-generated SQL will light up every raw-SQL rule in the ruleset, and that is not a finding, it is a category error — the [code-executor inversion](agentera-agently.html) this series has hit repeatedly. Out of scope by the policy, and correctly so. +- **4 `non-literal-import`** in `logging.py` and `system.py` — dynamic imports of optional dependencies, the standard pattern for a framework with 30 extras. +- **1 `run-shell-injection`**, the report's only novel High, on `.github/workflows/pytest-subset.yml:105`. Resolved below. +- **3 info-level meta findings** — the Semgrep partial-coverage warning, the unaudited-lockfile note, and the disabled-by-default AI review. + +## The seven in-scope claims, checked + +**1. Path traversal in the file tools — guarded, and guarded in the harder direction.** All three tools (`ReadFileTool`, `WriteFileTool`, `ListDirTool`) route through `safe_resolve_path(base, user_path)` in `langroid/utils/system.py:213`. The first thing worth noting is an anti-pattern that *isn't* one: all three discard the return value and then operate on the raw `self.file_path` inside a `chdir`. That is normally the bug — validate the canonical path, use the raw one. Here the docstring gets there first: + +> A `~` path is checked under *both* interpretations — expanded (as `read_file` reads it) and literal (as `create_file` and `list_dir` write/list it, since those do not expand) — and rejected if either one escapes. **Callers validate with this function but then operate on the raw path, so validating only one interpretation would leave the other unguarded.** + +The code does exactly that: it resolves the user path twice, once expanded and once literal, and rejects if *either* lands outside the base. The asymmetry it is defending against is real — `read_file` calls `expand_user_path`, `create_file` and `list_dir` do not — and it is the kind of divergence between siblings this series usually finds *unhandled*. Closes [GHSA-fg23-3346-88f5](https://github.com/langroid/langroid/security/advisories/GHSA-fg23-3346-88f5). + +**2. Repository and folder ingestion — guarded.** `langroid/parsing/repo_loader.py` resolves each candidate path and checks containment against the resolved root, with the failure modes written down: unreadable paths, permission errors, symlink loops, and dangling symlinks that `os.walk` lists anyway. The root is resolved once rather than per entry, with a comment explaining that `Path.resolve()` costs syscalls. Closes [GHSA-99m6-3pvg-q66h](https://github.com/langroid/langroid/security/advisories/GHSA-99m6-3pvg-q66h). + +**3. XXE and deserialization in the message parsers — guarded.** `XMLToolMessage` parses with `lxml`, which is the parser family where XXE is *real* rather than [DoS-only](ucbepic-docetl.html) — and it is configured `resolve_entities=False, load_dtd=False, no_network=True`, with the reasoning in a comment above it. A grep of the whole package finds no `pickle.load`, no `yaml.load`, no `marshal`, and no archive extraction at all — no `extractall`, no `tarfile`, no `ZipFile`. There is no decompression-bomb surface because there is no decompression. Closes [GHSA-pw95-88fg-3j6f](https://github.com/langroid/langroid/security/advisories/GHSA-pw95-88fg-3j6f). + +**4. Credential leakage in a default configuration — clean.** `.env-template` holds 40-odd placeholders (`your-key-here-without-quotes`) and nothing else. Gitleaks returned zero on the full history-less tree. The shipped `.chainlit/config.toml` enables multi-modal and file upload and disables speech-to-text; it does not enable the MCP feature, which matters for the dependency section below. + +**5. A missing call to a documented safety gate — none found, and finding that out took three passes.** This is the in-scope item that best rewards a sweep, so it got one. Both `eval()` sinks in the package — `table_chat_agent.py:242` and `vector_store/base.py:307` — are structurally identical: `if not self.config.full_eval: expr = sanitize_command(expr)`, then `eval(code, safe_eval_globals(vars), {})`. The globals restriction is applied *unconditionally*, including when `full_eval=True` disables the AST validator, so even the explicitly-out-of-scope path cannot reach `__import__`/`eval`/`exec` through implicit builtin injection. Two sinks, one shape, no drift. + +The sender-verification surface took longer and is the methodological find of this scan. It is documented below. + +**6. `allowed_statement_types` misclassification — guarded, including the nested case.** The policy specifically invites reports where the allowlist classifies a statement as one kind while the engine performs another, and calls that "a bounded, fixable defect." The validator already handles it: after checking the top-level node kind, `_nested_write_kinds()` walks the AST for embedded writes, with the reasoning inline — a data-modifying CTE (`WITH x AS (DELETE ... RETURNING *) SELECT ...`) and `SELECT ... INTO tbl` both parse with a `Select` top node while the database still performs the write. A query that trips it gets rejected with the embedded kind named in the error. Closes [GHSA-3gpx-vwr3-xvwx](https://github.com/langroid/langroid/security/advisories/GHSA-3gpx-vwr3-xvwx). An unparseable query is rejected rather than passed, which is the right direction to fail. + +**7. Vulnerable dependencies with demonstrated impact — none reachable.** Three candidates, all resolved against the code rather than the version number. Below. + +## The methodological find: three correct ways to say the same thing + +Langroid has been hit three times by one bug class, and the third instance is what makes the pattern legible. + +The seed was [GHSA-gjgq-w2m6-wr5q](https://github.com/langroid/langroid/security/advisories/GHSA-gjgq-w2m6-wr5q) (CVE-2026-54771): a tool registered `use=False, handle=True` — meaning *the LLM may not call this, but the agent will handle it* — could be invoked anyway by a chat user pasting raw tool JSON, because the dispatch path never checked whether the message came from `Entity.USER` or `Entity.LLM`. The fix added a `tainted` mark on USER-derived documents and a filter that drops handle-only tools out of tainted messages. + +Then it happened twice more, in the same shape: [GHSA-2j3c-5vm9-xppx](https://github.com/langroid/langroid/security/advisories/GHSA-2j3c-5vm9-xppx) (`RecipientTool`) and [GHSA-4fpx-72j9-gwg3](https://github.com/langroid/langroid/security/advisories/GHSA-4fpx-72j9-gwg3) (`RewindTool`). Both are *relabelling* bugs: a tool handler takes attacker-influenceable content out of its own field and re-emits it as a **new document labelled `sender=Entity.LLM`**, which arrives untainted and therefore trusted. The first fix had been scoped to `DonePassTool`/`AgentDoneTool` only, so both siblings were left unpatched. Each advisory says the same thing about why: *the handler has no `chat_doc` argument, so taint is structurally invisible to it.* + +That yields an obvious sweep — find every tool handler that lacks a `chat_doc` parameter — and the obvious sweep is **wrong**. Run it and 16 of 24 handlers come back "unguarded," including `SendTool` and `AgentSendTool`, which re-emit a `content` field verbatim and are exactly the shape of the two patched bugs. + +They are fine. There are three different correct idioms in this codebase, and only the first needs `chat_doc`: + +1. **Document-derived** — `tainted=chat_doc is not None and chat_doc.metadata.tainted`. Used by `RecipientTool`, `AddRecipientTool`, `RewindTool`, `PassTool`, `DonePassTool`, `ForwardTool`. +2. **Tool-level mark** — `tainted=self._tainted`, where `_tainted` was stamped onto the tool object by `_tainted_copy()` when it was parsed out of a tainted document. Used by `SendTool` and `AgentSendTool`. No `chat_doc` needed, because the taint rides the tool rather than the message. +3. **Implicit, via the tool list** — `AgentDoneTool` and `DoneTool` pass no `tainted=` argument at all, and are still correct, because `response_template()` computes `tainted=tainted or any(getattr(t, "_tainted", False) for t in tool_messages)` and both put `self` in `tool_messages`. The mark propagates without anyone naming it. + +A name-matched sweep sees idiom 1 and reports the other two as missing. This series has the inverse lesson on file — [a sweep that under-reports because it misses alias spellings](mai-with-u-maibot.html) — and this is the same failure in the other direction: **on default-deny code, a single-idiom sweep over-reports, and every false positive it generates looks exactly like the two real CVEs that preceded it.** The only way through is to enumerate the propagation idioms *before* counting violations. Eight handlers take `chat_doc`; the other sixteen either re-emit nothing attacker-controlled (the seven web-search tools return search results, not user fields) or carry taint by idiom 2 or 3. Zero gaps. + +Worth naming the one that could have been a finding and isn't: `arangodb_agent.py:140` and `:149` call `create_llm_response()` with no `tainted=` argument — the literal `RewindTool` defect signature. Both emit a fixed string (`"I give up, since I have exceeded the maximum number of tries"`). Nothing attacker-influenceable passes through them, so there is nothing to taint. + +## The dependency disagreement, which is the useful part + +The two dependency tools produced 83 advisories and 1 advisory against the same repository. They are both right, and the reason is the cleanest demonstration of this the series has recorded: + +| | pip-audit | Trivy | +| --- | --- | --- | +| Read | `pyproject.toml` floors | `uv.lock` | +| Packages | 181 | — | +| **nltk version seen** | **3.10.3** | **3.10.0** | +| nltk advisories | 1 | 17 | + +Same package, same repository, same scan, two versions. `pyproject.toml` declares `nltk<4.0.0,>=3.8.2` — an open floor, so a fresh resolve picks up 3.10.3 and sixteen of the seventeen advisories evaporate. `uv.lock` pins 3.10.0. Neither tool is wrong; they described **different install paths**, and a report that "reconciled" them into one number would have destroyed the only information that mattered. + +So the question becomes: who actually runs the lockfile? + +- **PyPI consumers** — `pip install langroid` — resolve from the floors. Not the lock. +- **The published Docker image** — the `Dockerfile` runs `uv pip install --no-cache-dir .`, which resolves from the floors. **Not** `uv sync`. Trivy scanned the `Dockerfile` target separately and returned **zero**. +- **CI and contributors** — `.github/workflows/pytest.yml` and `validate.yml` run `uv sync --dev`, and the `Makefile` drives everything through `uv run`. These get the lock. + +The 83 advisories therefore describe the **contributor and CI environment**, not any shipped artifact and not a single user of the library. That is a real de-rating — it moves the cluster from "users are exposed" to "the test suite runs on old pins" — but it is not zero, and one entry in it is worth a maintainer's minute. It is the only thing in this report that is: + +> **`gitpython` is pinned at 3.1.56 in `uv.lock`. [CVE-2026-78676](https://nvd.nist.gov/vuln/detail/CVE-2026-78676) is a critical RCE fixed in 3.1.59**: GitPython before 3.1.59 fails to safely re-serialize multi-line git-config values, so a crafted config with embedded newlines becomes a live directive — `core.hooksPath` — after any unrelated config write, giving arbitrary code execution via hook invocation. Twelve further gitpython advisories sit alongside it. + +What makes this one different from the other 82 is that **langroid imports gitpython in its own code** — `agent/tools/file_tools.py` (the `WriteFileTool` git-commit path) and `parsing/repo_loader.py`. It is not a transitive leaf that happens to be in the tree. `pyproject.toml` declares `gitpython<4.0.0,>=3.1.43`, so every consumer install already resolves past it; only the environment CI runs in, and that contributors get from `uv sync`, is pinned behind. The fix is `uv lock --upgrade-package gitpython` and costs nothing. + +Adjacent and structural: **there is no `.github/dependabot.yml`.** With no bot watching `uv.lock` and consumers resolving from floors that always look current, a stale lock is close to invisible — nothing in the project's own feedback loop reports it. That is the [lockfile-coverage gap](whiteguo233-openbiliclaw.html) this series keeps finding, in its quietest form: not a wrong pin, just nothing configured to notice. + +The three remaining critical-severity entries all fail the reachability gate, and each fails it for a different reason worth recording: + +- **`unstructured` 0.18.32, CVE-2026-71428 (critical SSRF)** — the CVE is specific to the `url` argument of `partition`, `partition_html` and `partition_md`, fetched without host validation. Langroid calls `partition_pdf`, `partition_docx` and `partition_doc` — the **file**-based entry points — and never passes a URL to any of them. Affected surface, not reachable surface: the same split that retired the [LiteLLM-proxy CVEs](klavis-ai-klavis.html). +- **`chainlit` 2.11.1, CVE-2026-45018 (critical command injection via the MCP stdio transport)** — requires `features.mcp.enabled = true` in `.chainlit/config.toml`. Langroid ships that file and does not enable MCP. Fixed in 2.12.0, and the floor (`>=2.0.1,<3.0.0`) already admits it. +- **`chromadb` 0.4.23, CVE-2026-45833 (critical code injection)** — the odd one out, because it is **not** lock-only: `pyproject.toml` ceiling-pins `chromadb<=0.4.23`, so anyone installing `langroid[chromadb]` gets this version by construction, and Trivy reports **no fixed version at all**. It survives anyway, on surface: both chromadb CVEs describe the ChromaDB **server** — `/api/v2/tenants/{...}/collections/{id}` endpoints, an "authenticated attacker", `trust_remote_code` on a server-side model repository. Langroid uses chromadb as an embedded client library and stands up no such endpoint. A ceiling pin with no patched version above it is worth knowing about; it is not a vulnerability in langroid. + +## What Semgrep could not parse, read by hand + +Three Semgrep errors, all `PartialParsing`, meaning the rules for those two files did not run. Absence of findings there is absence of evidence, so: + +**`.github/workflows/docker-publish.yml`** interpolates `${{ env.DOCKER_TAG }}` — derived from `${GITHUB_REF#refs/tags/}` — directly into `run:` blocks that call `docker manifest`. A tag name containing shell metacharacters would inject. The trigger is `push` to `main` and to tags, so creating that tag requires write access to the repository, and `run-shell-injection` severity is a function of the trigger: an injection reachable only by someone who can already push is not a boundary crossing. Same conclusion for `${{ github.ref_type }}`, which is `branch` or `tag` and nothing else. + +**`Dockerfile`** clones langroid over HTTPS from its canonical URL, renames `.env-template` to `.env` (placeholders only), and installs with `uv pip install .`. The one line worth a raised eyebrow is `sh -c "$(wget https://raw.githubusercontent.com/.../oh-my-zsh/master/tools/install.sh -O -)"` — piping a remote script from a `master` branch into a shell, unpinned. In a developer convenience image whose `CMD` is `zsh`, with a `|| true` after it, this is a documented-anywhere-else-too pattern and not something to file; it is recorded here because it is exactly what the Dockerfile rules would have flagged had they run. + +The one novel High, **`pytest-subset.yml:105**, resolves the same way — and the maintainer got there first. The workflow interpolates `${{ inputs.pytest_args }}` straight into a `run:` block, above a comment reading: *"input is substituted directly; workflow_dispatch is restricted to users with write access, so this is not an injection vector."* The trigger really is `workflow_dispatch` only. That is an accepted risk with the correct reasoning attached, which is a different object from an oversight, and it deserves to be credited as one. + +## Patterns observed + +**A security policy that names its non-boundaries is worth more than one that promises safety.** The most useful sentence in langroid's `SECURITY.md` is the admission that `sanitize_command()` and the SQL denylists are *not* security boundaries — that they stop an unlucky LLM, not a determined attacker. Most projects in this position claim the opposite, and their users deploy accordingly. By refusing the claim and naming the real boundaries — database role, container, egress — the document moves the defence to where it can actually hold, and simultaneously makes the project *auditable*: it converts "is this safe?" into seven checkable propositions. This scan exists because those propositions were checkable. + +**Sixteen advisories is not a smell; it is a shape.** It would be easy to read langroid's advisory list as a bad sign. The opposite reading fits the code better: nearly every one of those advisories has left a comment behind at the site it fixed, naming the GHSA and explaining the reasoning. `safe_resolve_path` carries the both-interpretations argument. The nested-write check carries the CTE example. The eval sites carry the builtins-injection note. `RecipientTool` carries "do not let that relabel launder untrusted content." The code has accumulated its own security history in the places where that history is load-bearing, which is why a reviewer arriving cold can check seven claims in an afternoon instead of reconstructing the threat model from scratch. + +**The repeated bug class is the one to watch, and the repetition is informative.** Three advisories, one mechanism: relabel USER content as LLM content and the trust filter stops applying. The first fix was scoped to two tools; two siblings were found later. The response was not a third point-fix but a *generalisation* — a tool-level `_tainted` mark plus implicit propagation through `tool_messages` — which is why `SendTool` and `AgentSendTool` are already correct without ever having been the subject of an advisory. The lesson for a reviewer is the one in the section above: when a project fixes a class by generalising, single-idiom sweeps stop working, and the reviewer's job shifts from *counting violations* to *enumerating the idioms first*. + +**Zero real findings, and the interesting part is which question failed.** The tools produced 132 findings and none of them pointed at anything. That is now routine — it has happened 33 times. What is not routine is that the hand sweep also came back empty *against a checklist the maintainer wrote himself*. The advertised-boundary test is only meaningful if it can fail, and this series has published it failing: a boundary [advertised but not implemented](mims-harvard-tooluniverse.html), a guard [wired to the wrong route set](liaohch3-claude-tap.html), a [deny-list that confessed](mljar-mercury.html) what its `@authenticated` decorator did not cover. Here it passed, seven for seven. Publishing the pass is the only thing that makes the failures worth reading. + +## Notes on the tool + +- **The unaudited-lockfile meta finding did its job, and should say more.** It correctly flagged that pip-audit read `pyproject.toml` while `uv.lock` went unread — but Trivy *had* already covered the lock in the same run, so the warning as phrased ("not covered by the dependency scan") overstates the gap. The scanner knows both tools' targets; it could reconcile them and say the more useful thing: *two install paths were audited by two different tools, and here is where their package versions disagree.* The nltk 3.10.0-vs-3.10.3 split is exactly the output a user wants and the scanner currently makes them derive by hand. Backlog item. +- **`PartialParsing` errors need the same treatment `timeout` errors already get.** `scan_semgrep` emits `semgrep-partial-coverage` naming the `rule -> file` pairs that did not run, which is right, but it phrases the recommendation around raising `--timeout` — useless advice for a parse failure, which will never succeed on retry. A parse error on a `Dockerfile` or a workflow means *those rule families produced nothing for this file*; the recommendation should say "hand-review, these rules did not run" rather than suggest a re-run. +- **A `--dep-surface` column is the recurring ask.** Three of the six criticals in this report were retired by asking "which API does the CVE actually name, and does this repository call it?" That question is mechanical enough to assist: Trivy's advisory text usually names the affected function (`partition(url=...)`, the `/api/v2` endpoints, `features.mcp.enabled`), and the scanner already has the repository on disk to grep. Not automatic triage — just surfacing the affected symbol next to a yes/no on whether it appears in the tree would cut the manual pass substantially. +- **No tool in the stack can see any of the seven in-scope questions.** Taint propagation across three idioms, a `~`-expansion asymmetry between sibling helpers, a nested-write classifier, an lxml parser's entity flags — Semgrep found the `eval()` calls and the raw SQL, which are the parts that are *supposed* to be there, and found nothing about the machinery that makes them safe. This is the standing gap the series keeps recording, and it is why the hand sweep is not optional. + +## Disclosure timeline + +- 2026-09-12 — scan run at `053dbfe` +- 2026-09-12 — curation complete: zero real findings, quality gate not met, nothing filed +- 2026-09-12 — public post (this page) + +Nothing was reported to the maintainers, because nothing met the bar. The `gitpython` lock pin and the absent `dependabot.yml` are published here in full rather than sent privately: they are hygiene items affecting the contributor environment, visible to anyone reading `uv.lock`, and not a vulnerability in the shipped library. + +## Reproduce + +```bash +git clone https://github.com/langroid/langroid /tmp/scan-target +.venv/Scripts/python.exe scanner/run_scan.py \ + --repo /tmp/scan-target \ + --reports-dir ./reports/langroid-langroid \ + --min-severity medium +```