From a642e30b15c92e12007a55f6a705f23e431fb737 Mon Sep 17 00:00:00 2001 From: elfrost <5491654+elfrost@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:07:41 -0400 Subject: [PATCH] docs: mercury GHSA-8hq5-94w5-f4ff fixed and published (28th fix) The maintainers released the fix in Mercury 3.2.12 on 2026-10-24 and published the advisory on 2026-10-26 (CVSS 3.1 5.3 Medium, affected > 3.0, <= 3.2.11). The embargo has lifted, so the post now names the withheld handler and parameters at the advisory's level of detail. Recorded from the published advisory. The original differential has not been re-run against 3.2.12, and the post says so. Co-Authored-By: Claude Opus 5.5 --- docs/fixed.md | 5 ++-- docs/index.md | 8 ++--- docs/scan-log.md | 2 +- docs/scans/mljar-mercury.md | 58 +++++++++++++++++++++++++++++++++++-- docs/work-with-me.md | 4 +-- 5 files changed, 65 insertions(+), 12 deletions(-) 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.