diff --git a/docs/fixed.md b/docs/fixed.md index b77d298..899aabc 100644 --- a/docs/fixed.md +++ b/docs/fixed.md @@ -1,12 +1,12 @@ --- layout: default title: Findings a maintainer fixed -description: "The 27 scans in the AI PatchLab series where the maintainer shipped a fix — what was reported, and what landed upstream." +description: "The 28 scans in the AI PatchLab series where the maintainer shipped a fix — what was reported, and what landed upstream." --- # Findings a maintainer fixed -Of 114 scans, **27** ended with a maintainer shipping a fix. This page is the +Of 114 scans, **28** ended with a maintainer shipping a fix. This page is the short version of the argument: a report is only worth writing if someone can act on it. The fastest turnaround in the series was about six hours from filing to a merged pull @@ -18,6 +18,7 @@ release notes. | Date | Repository | Raw findings | Real | |---|---|---:|---| | 2026-09-14 | [superlinked/sie](scans/superlinked-sie.html) | 156 | 1 real | +| 2026-09-11 | [mljar/mercury](scans/mljar-mercury.html) | 140 | 1 real — withheld | | 2026-09-03 | [samuelgursky/davinci-resolve-mcp](scans/samuelgursky-davinci-resolve-mcp.html) | 97 | 1 real | | 2026-08-31 | [shy3130/tick-stock-panel](scans/shy3130-tick-stock-panel.html) | 77 | 1 real | | 2026-08-29 | [ginlix-ai/LangAlpha](scans/ginlix-ai-langalpha.html) | 372 | 1 real | diff --git a/docs/index.md b/docs/index.md index 471f119..43c237d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- layout: default title: AI PatchLab Scans -description: "114 curated security scans of open-source AI agents, MCP servers and LLM apps - 27 confirmed fixes, run local-first with Semgrep, Gitleaks, Trivy and pip-audit." +description: "114 curated security scans of open-source AI agents, MCP servers and LLM apps - 28 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 }}). 114 scans, 27 confirmed fixes, methodology in the open. +> [**work with me →**]({{ '/work-with-me' | relative_url }}). 114 scans, 28 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; @@ -35,7 +35,7 @@ remediation and confidence rules to normalize the findings. ## Two shorter ways in -- [**Findings a maintainer fixed**]({{ '/fixed' | relative_url }}) — the 27 that resolved +- [**Findings a maintainer fixed**]({{ '/fixed' | relative_url }}) — the 28 that resolved upstream. The shortest version of the argument: a report is only worth writing if someone can act on it. - [**Scans that found nothing**]({{ '/clean' | relative_url }}) — 41 of them, published as @@ -131,7 +131,7 @@ filed, which is the usual outcome of a clean scan. | 2026-09-14 | [superlinked/sie](scans/superlinked-sie.html) | 156 | 1 real | **fixed** | | 2026-09-13 | [bubbuild/bub](scans/bubbuild-bub.html) | 72 | 1 real | declined | | 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 | accepted | +| 2026-09-11 | [mljar/mercury](scans/mljar-mercury.html) | 140 | 1 real — withheld | **fixed** | | 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 | | 2026-09-08 | [chigwell/telegram-mcp](scans/chigwell-telegram-mcp.html) | 38 | 0 real | — | diff --git a/docs/scan-log.md b/docs/scan-log.md index 68ea034..5cdd520 100644 --- a/docs/scan-log.md +++ b/docs/scan-log.md @@ -22,7 +22,7 @@ Every scan in the series, newest first, with the summary written on the day of t - **2026-09-14** — [superlinked/sie](scans/superlinked-sie.html) — 156 findings, **1 real** — an **open-source inference server and production cluster** (3.3k★, Apache-2.0; a polyglot monorepo — 306 non-test Python modules, a 180-file Rust gateway, a TypeScript SDK, a Helm chart). Picked on responsiveness again, on the *smallest* number in the shortlist: **six open issues, every one carrying maintainer comments**, zero ghosted. The product ships something rare — a **hand-written OAuth 2.0 bridge**, so claude.ai connectors (OAuth-only, no pasted bearer token) can authenticate against a per-user connector secret — and it is unusually careful OAuth: PKCE **S256-only** with `plain` refused in a comment, `secrets.compare_digest`, single-use 120s codes bound to `client_id`, an **exact-match** redirect allowlist, and one validator applied to both GET and POST so "a direct POST cannot skip the invariants the GET handler checks." Its docstring claims secrets and codes are **NEVER logged**; read as a [test oracle](scans/hkuds-openopc.html), the claim holds exactly — `oauth.py` has **zero** logging calls. Every property the docstring claims is implemented; **the hole is the one it does not claim**. `auth.py:36` derives the server's own public origin from `X-Forwarded-Host` / `X-Forwarded-Proto` with no allowlist, and stamps it into RFC 8414 `issuer`/`authorization_endpoint`/`token_endpoint`, RFC 9728 `authorization_servers`, and the `WWW-Authenticate` challenge — all on `_EXEMPT_PATHS` that answer **before** any credential check, so the poisoning input needs no credential. Executed against the project's own routes under the **shipped Helm defaults** with a control: header absent, the code is correct; one header, and every endpoint becomes `https://evil.attacker.example/...`. It bites because the authorize page is where a user **types their connector secret**, and the correct redirect allowlist cannot help — the victim never reaches the real authorize endpoint. **Stated honestly, the chart blocks it**: `mcpEdge.ingress.host` is `required` and ingress-nginx overwrites the header, so the default ingress path is safe — the exposure is `sie-mcp serve` on **`0.0.0.0:8088`** with no proxy, `use-forwarded-headers: "true"`, or an unkeyed cache. Medium, not High. What makes it a finding is that the project **documents the behaviour twice and both times calls it a *stability* concern** ("so the OAuth metadata URLs are stable"): the [contract and artifact](scans/ginlix-ai-langalpha.html) agree on behaviour and disagree on risk, so the **reframing is the deliverable**. The fail-closed fix was [written before it was proposed](scans/roflcoopter-viseron.html) and **rejected** — it would break the project's own `mise run mcp-serve` — leaving two that break nothing: default `SIE_MCP_PUBLIC_URL` from the ingress host the chart already requires, and bound the fallback (note that uvicorn's `--forwarded-allow-ips` is **silently ineffective** here, since the code reads raw headers off the ASGI scope). **153 tool findings, zero real.** The scary tier is 87 Trivy CVEs in `bun.lock` — while `pnpm-lock.yaml`, same directory, same workspace, reports **zero**. Don't average them, ask [which install path each describes](scans/langroid-langroid.html): `package.json` declares `packageManager: pnpm`, all CI runs `pnpm install --frozen-lockfile`, and a `pnpm.overrides` block already pins **exactly these packages** patched (`protobufjs>=7.5.8`, `postcss>=8.5.10`, `vitest^4.1.0`, `ws>=8.20.1`). **Bun does not honour `pnpm.overrides`** — that one fact is the entire 87-finding delta. The 23 `logger-credential-disclosure` hits are a **pun**: every one is an ML *tokenizer* token (`token_dim`, `add_tokens`, `kv_budget_tokens`) in a product made of tokens. All 16 shell-injection hits are `workflow_call` reached only from `release.yml` (`push: main` / `release` / `dispatch` — write access already). Two Gitleaks hits are **SHA-256 integrity pins** — a control flagged as a leak, the [active-harm inversion](scans/roflcoopter-viseron.html) in its mildest form. **The defences no scanner can credit**: a **hand-written msgpack-numpy object hook** that refuses `kind=O` ("invokes `pickle.loads`") and `kind=V`, then validates dtype, dimension sign and `shape × itemsize` against actual byte length; all six `torch.load` calls already `weights_only=True`; 19 `yaml.safe_load` and **not one** unsafe load. pip-audit's zero was **actively misleading** — root `pyproject.toml` says `dependencies = []` ("the root is a development environment, not a distribution") — and my own `dependency-scan-unaudited-lockfile` meta-finding caught it before I did, third time in a month; Trivy then confirmed `uv.lock`, both `Cargo.lock`s and `pnpm-lock.yaml` all **0**. Semgrep's only 3 rule timeouts hit a vendored minified bundle, so first-party coverage is real. No `SECURITY.md` (root, `.github/`, `docs/`, published site all absent), PVR **off** · one focused public issue filed ([#275](https://github.com/superlinked/sie/issues/275)), leading with the executed differential and the two non-breaking fixes · ✅ **Resolved 2026-09-17 in 3 days (24th fix in the series)** — the maintainer shipped both proposed fixes and went further: `base_url` stopped reading `X-Forwarded-*` entirely and an untrusted `Host` now gets a 503, which also makes uvicorn's `FORWARDED_ALLOW_IPS` the control that actually applies; the docs now give the *security* reason for pinning, and the differential became four regression tests; a day later the maintainer hardened the new `Host` check itself in [#305](https://github.com/superlinked/sie/pull/305) — it had split and prefix-matched the header, so `localhost:8088@evil.example` passed as loopback — by parsing `Host` per RFC 9110 before trusting it (re-verified on h11 and httptools: the userinfo probes that leaked before #305 no longer do, and every legitimate host still resolves) - **2026-09-13** — [bubbuild/bub](scans/bubbuild-bub.html) — 72 findings, **1 real** — **a hook-first Python runtime for agents that "live alongside people"** (1.6k★, Apache-2.0, 53 Python files; picked over a 4.7k★ rival purely on responsiveness — eight distinct authors merging PRs in 60 days and **zero open issues**). Bub has what most local-first agent projects lack: a real remote trust boundary. A CLI agent's threat model is "you already have the shell"; a Telegram-connected one is "who on the internet may drive this?", and the answer is twenty lines in `channels/telegram.py`. Those lines admit a caller whose numeric ID **or** current `@username` is in `BUB_TELEGRAM_ALLOW_USERS`. Those identifiers are not alike: an ID is immutable, a username is **rented** — releasable by its owner, claimable by anyone, tradeable on Telegram's own marketplace. The project's reference table says the setting holds "user IDs"; `env.example` and three operator-facing docs all show `your_username`. The code implements the tutorials. Driving the shipped `_on_message` shows a wholly unrelated account (`id=999999999`) admitted on the handle alone — and an admitted caller reaches the **default** `bash` tool (`resolve_tool_names(None)` returns the whole registry), in a container the Dockerfile runs as root. The [composite class](scans/langflow-ai-openrag.html): nothing in `telegram.py` is badly written, nothing in the docs is wrong alone, and no rule reads `env.example` next to `_on_message`. Two siblings fell out of the same twenty lines — the comparison is case-sensitive where Telegram is not (locks out the legitimate operator, fails closed), and `_on_start` checks only the *chat* allowlist while `_on_message` checks both. The fix is a **product decision** — reject non-numeric entries and break every operator who followed `env.example`, or keep them and warn — so it was [posed, not patched](scans/theroyallab-tabbyapi.html). The one that didn't survive: `bub login openai` skips OAuth `state` validation whenever the caller omits it (`auth.py:215`), the classic [conditional-verification shape](scans/sentelabsai-openexecutive.html) on a loopback callback a web page can reach — **retired because PKCE is implemented correctly** (S256, fresh verifier per flow), so an injected code dies at the token endpoint. Both dependency install paths came back clean, which after [83-versus-1](scans/langroid-langroid.html) on langroid is worth naming. 57 of the 72 findings, including the only Critical, are the docs site's npm tree: severity ranking and interest ranking perfectly inverted. - **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-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 · ✅ **Resolved 2026-10-26 in 45 days (28th fix in the series)** — fixed in 3.2.12 (2026-10-24) and published as [GHSA-8hq5-94w5-f4ff](https://github.com/mljar/mercury/security/advisories/GHSA-8hq5-94w5-f4ff) (CVSS 3.1 5.3 Medium): the maintainers took the stricter of the two proposed remedies, removing the request-controlled `dir`/`recursive` listing overrides so the root and recursion policy come from server configuration, with regression tests for absolute and relative paths, recursion overrides and directory-existence behaviour. Recorded from the published advisory; not re-verified by this series. - **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 - **2026-09-08** — [chigwell/telegram-mcp](scans/chigwell-telegram-mcp.html) — 38 findings, **zero real after curation; thirty-second clean scan** — an **MCP server that hands an agent a whole Telegram account** (1.6k★, Apache-2.0, 80+ tools over Telethon, 37 merged PRs from 26 authors in 60 days). **The entire `Critical`+`High` tier is one dependency file no install path touches**: Trivy read `poetry.lock` (`h11 0.14.0`, `mcp 1.6.0`, `starlette 0.46.2`, `rsa 4.2`, `pyasn1` …) and reported twenty advisories — but the `pyproject.toml` has **no `[tool.poetry]` section**, the README installs with `uv sync` (`uv.lock` pins `mcp 1.29.0` / `starlette 1.6.0` / `h11 0.16.0` / `rsa 4.9.1`, all patched), the Dockerfile installs from `requirements.txt` open floors that resolve to current releases, and the Poetry lines in the Dockerfile are **commented out**. A committed lockfile that nothing generates and no installer reads, driving the whole scary tier — the inverse of [OpenBiliClaw](scans/whiteguo233-openbiliclaw.html), where two lockfiles were *both* real install paths. **The scariest line, "DNS Rebinding Protection Disabled by Default in MCP", is inert**: the shipped `mcp 1.29.0` has it **on** by default — verified by executing the SDK, `allowed_hosts` pinned to `127.0.0.1:*`/`localhost:*`/`[::1]:*` and passed into the streamable-HTTP session manager — and the compose binds `127.0.0.1` only. The rest is placeholder Gitleaks (`.env.example` API-hash, a README proxy-secret example, two test fixtures), ten GHA mutable-tag hardening hits, a SHA-1 that names a lock file (not auth), and an [`os.chmod(dir, 0o700)`](scans/realiti4-claude-swap.html) the rule wants to *loosen* — one scan after five of the same. **The defenses a scanner cannot credit are the story**: file-path tools are **deny-all** unless the MCP client advertises Roots (server roots need an explicit opt-in), prompt-injection is handled by a structural JSON boundary rather than a keyword denylist, the session string is never logged, and an `install_guard` refuses to run if the installed distribution points at the PyPI name-squat. The one honest tool signal was the pip-audit coverage note on `requirements.txt`. Not strict-norm (no `SECURITY.md`, PVR off) · post-only, nothing filed diff --git a/docs/scans/mljar-mercury.md b/docs/scans/mljar-mercury.md index 9003c18..f5e9de1 100644 --- a/docs/scans/mljar-mercury.md +++ b/docs/scans/mljar-mercury.md @@ -9,7 +9,55 @@ date: 2026-09-11 **Repository:** [mljar/mercury](https://github.com/mljar/mercury) **Commit scanned:** `b02bca0` **Scan date:** 2026-09-11 -**Disclosure status:** reported privately — detail withheld pending maintainer response +**Disclosure status:** ✅ **resolved** — filed privately as +[GHSA-8hq5-94w5-f4ff](https://github.com/mljar/mercury/security/advisories/GHSA-8hq5-94w5-f4ff), +accepted on 2026-09-14, fixed in +[v3.2.12](https://github.com/mljar/mercury/releases/tag/v3.2.12) and +**published by the maintainers on 2026-10-26**, forty-five days after filing. The +embargo has lifted; the specifics are below. This series has not re-run its own check +against the fixed release (see the update). + +## Update — 2026-10-26: fixed and published + +The advisory is public, so this page no longer needs to talk around the finding. + +**[GHSA-8hq5-94w5-f4ff](https://github.com/mljar/mercury/security/advisories/GHSA-8hq5-94w5-f4ff)** +— *Unauthenticated notebook enumeration outside the configured directory via `?dir=` +(NotebooksAPIHandler).* Medium (CVSS 3.1 5.3, `AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N`), +CWE-22 / CWE-200 / CWE-548, affected `> 3.0, <= 3.2.11`, patched in **3.2.12** +(released 2026-10-24). The advisory recommends +[3.2.13](https://github.com/mljar/mercury/releases/tag/v3.2.13) or later, which adds +further security improvements on top of this fix. + +**What was withheld.** Mercury's standalone app has two sibling handlers that list +notebooks. `RootIndexHandler` (`mercury_app/root.py`) always lists the server's +configured `notebooks_dir`. `NotebooksAPIHandler` (`mercury_app/notebooks.py`, lines +22–24 at the scanned commit) read two query parameters, `dir` and `recursive`, and used +them to choose which directory to list and how deep to go. In the shipped default the +Jupyter token is empty, so `@authenticated` passes for the auto-generated anonymous +user, and the firewall's block-list did not include this route. An anonymous visitor +could therefore list the notebooks in any directory the server process could read, with +their titles and descriptions, and learn whether an arbitrary directory existed. The +bound held as the original post described it: no notebook content and no file reads, +because the read path goes through Jupyter's contents manager, which rejects `..`. The +advisory states the same limit in its own words: the issue "does not establish +arbitrary notebook-content reads outside the served root, arbitrary file reads or +writes, or command execution." + +**The fix is the stricter of the two remedies the report offered.** The report proposed +either dropping the overrides so the handler behaves like its sibling, or confining +`dir` under the real path of `notebooks_dir`. Per the advisory, 3.2.12 "removes the +request-controlled listing overrides and derives the root and recursion policy from +server configuration," with regression tests for absolute and relative paths, recursion +overrides and directory-existence behaviour. No request can choose the root at all any +more, so there is no confinement check left to get wrong. + +**Not re-verified by this series.** Before writing *resolved*, this series normally +re-runs the original differential against the shipped fix: the served notebook still +lists, a traversal read still returns `404`, and the escape no longer works. That has +not been done for this entry. It is recorded from the maintainers' published advisory, +which is specific about what changed and what the tests cover, but it is their account, +not an independent check. ## Summary @@ -134,8 +182,12 @@ promise rather than in a public issue. report as reporter. Accepted is not the same as fixed — a draft advisory means the maintainers agree there is something to advise on, and the fix and publication are still theirs to schedule. -- *pending* — fix and advisory publication -- *pending* — public detail here, once fixed or after a reasonable window +- 2026-10-24 — fix released in Mercury 3.2.12 (GitHub release and PyPI); the advisory + was updated with the affected range `> 3.0, <= 3.2.11` and patched version 3.2.12 +- 2026-10-26 — **advisory published by the maintainers** (CVSS 3.1 5.3 Medium), shortly + after the 3.2.13 release; detail lifted on this page the same day +- 2026-10-26 — resolution recorded from the published advisory. **Not re-verified by + this series:** the differential that found the gap has not been re-run against 3.2.12. ## Reproduce diff --git a/docs/work-with-me.md b/docs/work-with-me.md index b936e26..721e90c 100644 --- a/docs/work-with-me.md +++ b/docs/work-with-me.md @@ -1,7 +1,7 @@ --- layout: default title: Work with me — AI agent & LLM security review -description: "Independent security review of AI agents, MCP servers and LLM applications. 114 public scans and 27 confirmed fixes as the proof; first-look engagements from $750." +description: "Independent security review of AI agents, MCP servers and LLM applications. 114 public scans and 28 confirmed fixes as the proof; first-look engagements from $750." --- # Work with me @@ -12,7 +12,7 @@ If you're shipping an agent framework, an MCP server, a RAG pipeline, or anythin ## The proof is public -Everything I'd do for you, I've done in the open — [114 curated scans]({{ '/' | relative_url }}) of well-known AI/agent projects, with **27 confirmed fixes** where maintainers acted on the findings, and a documented methodology behind each one. +Everything I'd do for you, I've done in the open — [114 curated scans]({{ '/' | relative_url }}) of well-known AI/agent projects, with **28 confirmed fixes** where maintainers acted on the findings, and a documented methodology behind each one. - **Real findings, not scanner noise.** A typical scan starts at hundreds of raw findings and ends at a handful that matter — because the rest are false positives, by-design patterns, or vendored third-party code. The curation is the value. - **Reachability over rule-count.** A `subprocess(shell=True)` that's safe under operator trust can become a multi-tenant sandbox escape once you trace who reaches it — and a scary-looking SQL string can be fully gated by a five-character allowlist. The verdict goes wherever the *code* goes, which means reading the code, not the rule.