mcp-security-scanner
A static security scanner for Model Context Protocol servers. Point it at an MCP server repo; it reads the source and flags the vulnerability classes that actually show up in production MCP servers — with a severity, a file:line, a remediation, and an honest confidence on every finding.
[!info] What this is, plainly This is static analysis, not a prover. It reads code; it does not run your server, and it does not prove any finding is remotely exploitable. It produces a prioritized review queue, not a verdict. A "clean bill" means these detectors found no critical/high patterns — not a security guarantee. That boundary is printed on every report on purpose.
[!warning] Not to be confused with the other PyPI package named
mcp-security-scannerThere is an unrelated project on PyPI under the plain namemcp-security-scanner(a runtime pentester that connects to a live MCP server over HTTP/SSE). This repo is a different tool: it performs static analysis of server source code, offline, with no network connection to the target. Because the plain name was already taken, this project's PyPI distribution is published asjaimenbell-mcp-security-scanner; the console command (mcp-scan) and the import package (mcp_scanner) are unaffected.
What it scans
Seven detector families. The first six are grounded in a real finding from a fleet-wide audit of production MCP servers; the seventh (added 2026-07-21) covers scheduled jobs, wrappers, and IaC/CI files — cron, systemd, GitHub Actions, PowerShell/bash/batch deploy scripts:
| # | Class | Detects |
|---|---|---|
| 1 | Codegen / template injection | Jinja autoescape off in a code-generating tool that renders untrusted fields into generated source; hand-rolled replace('"','\"') escaping instead of a real serializer (the mcp-factory class). |
| 2 | Tool-param injection | subprocess(shell=True), os.system, eval/exec on non-constants, pickle.load, yaml.load without SafeLoader, SSRF (caller-influenced fetch URL, no allowlist), path traversal (variable file path, no containment). |
| 3 | Auth / network posture | Bind on 0.0.0.0 (escalates when paired with debug=True), Werkzeug/uvicorn debug=True, mutating routes (POST/PUT/DELETE/PATCH) with no auth dependency, no rate limiter on a networked server. |
| 4 | Secret handling | Tracked .env / *.pem / *.key / keypair JSON, hardcoded secret literals (value-shape + secret-named assignments), secrets passed to log/print. |
| 5 | Write-tools-on-by-default / tool-scope-creep (added 2026-07-19) | A mutating @mcp.tool()/@server.tool()-registered tool (name/verb or dangerous-sink-body heuristic, one hop through a delegated helper) with no visible gate -- decorator, env-flag opt-in, or permission check. |
| 6 | Secret-leak-via-tool-response (added 2026-07-19) | An @mcp.tool()/@server.tool() function whose return expression hands back os.environ, a whole config/settings object, or a secret-named/secret-shaped value to the calling LLM. |
| 7 | Job hazards (added 2026-07-21) | Scans .yml/.yaml/.ps1/.sh/.bash/.bat/.cmd/.service/.timer files for: over-broad credential/ACL scope (permissions: write-all, icacls ... Everyone:F, chmod 777, IAM Action/Resource wildcard pairs); a destructive call (rm -rf, Remove-Item -Recurse -Force, terraform destroy, kubectl delete, git push --force/reset --hard, DROP TABLE, docker system prune/volume rm, schtasks /delete, aws s3 rm --recursive) with no confirm-before-destroy gate (escalates to P0 when -Confirm:$false actively disables the built-in prompt); and success-reported-without-verification (` |
Every finding carries severity (P0 critical → P3 hardening nit), confidence (high / medium / low), the offending file:line, and a concrete fix.
Install / run
# from the repo root
python -m mcp_scanner.cli <path-to-mcp-server> # markdown report
python -m mcp_scanner.cli <path> --json # JSON
python -m mcp_scanner.cli <path> --client-report --client-name "Acme" # 8-section client report
python -m mcp_scanner.cli <path> --fail-on P1 # CI gate: exit 2 on P0/P1
python -m mcp_scanner.cli <path> --fail-on P1 --include-cli-only-in-gate # also gate on cli-only findings
python -m mcp_scanner.cli --self-audit # scan your own fleet's servers
--fail-on excludes reachability: cli-only findings by default — a
cli-only finding's only known caller traces to a non-tool entrypoint (argv/
CLI-main, an admin script, a test file), never a registered MCP tool, so it
does not block a build gate unless your CLI/admin surface is itself part of
the attacker-reachable scope you want gated (a publicly-exposed management
CLI, say) — pass --include-cli-only-in-gate to opt back in.
--self-audit reads the directory to scan from the MCP_SCANNER_FLEET_ROOT
environment variable — there is no baked-in default, so this repo carries no
personal path. Set it to the directory containing the MCP server repos you
want audited:
export MCP_SCANNER_FLEET_ROOT=/path/to/your/mcp/repos # bash
$env:MCP_SCANNER_FLEET_ROOT = "C:\path\to\your\projects" # PowerShell
Without it set, --self-audit exits 1 with a clear error rather than
silently defaulting anywhere.
Or install the console script:
pip install -e .
mcp-scan <path-to-mcp-server>
The scanner reads only git-tracked source (falling back to a filtered tree walk in non-git dirs), so vendored deps, caches, and vector stores are skipped.
The dogfood proof (--self-audit)
The scanner is validated against eight real, in-production MCP servers — the strongest trust signal we can offer in a market that has been burned by simulated proof-of-work. Running --self-audit (live output, 2026-07-23):
CLEAN mcp-factory P0=0 P1=0 P2=0 P3=0 <- codegen-injection class fixed upstream 2026-07-21
FINDINGS github-mcp P0=0 P1=2 P2=0 P3=0 <- 2x P1/LOW: its own fake test tokens, honestly reported
CLEAN bus-mcp P0=0 P1=0 P2=0 P3=0
CLEAN desktop-mcp P0=0 P1=0 P2=1 P3=0 <- one P2/low heuristic note, clean bill
CLEAN rag-mcp P0=0 P1=0 P2=1 P3=0 <- one P2/low heuristic note, clean bill
FINDINGS discord-mcp P0=0 P1=1 P2=0 P3=0 <- 1x P1/LOW: TEST_TOKEN fixture, honestly reported
CLEAN rails-mcp P0=0 P1=0 P2=0 P3=0
CLEAN vllm-ops-mcp P0=0 P1=0 P2=1 P3=0 <- one P2/low heuristic note, clean bill
This is the acceptance test (tests/test_self_audit.py). Two notes on how to read it honestly: (1) the mcp-factory codegen-injection finding the original manual audit surfaced was genuinely fixed upstream (fleet drift reconciled 2026-07-21) — the detection class itself stays proven against tests/fixtures/vuln_codegen, and the test now pins current fleet reality (mcp-factory clean) rather than asserting a vuln that no longer exists. (2) The test's clean bar is "no HIGH/MEDIUM-confidence P0/P1": under the one law (below), a fleet repo whose own test fixtures embed an obviously-fake secret now shows a LOW-confidence P1 instead of zero findings — the severity-only clean_bill reports that as FINDINGS (github-mcp, discord-mcp above), which is the honest reading, and the test deliberately does not re-suppress it.
Honest capability boundary
- Manifest-aware reachability (built 2026-07-21; low-level MCP SDK shape added 2026-07-23, N-vote-hardened across two rounds the same day). After the detectors run, the scanner discovers the registered MCP tools (
@mcp.tool()/server.tool(...)registrations, the low-level SDK'sServer()+@server.list_tools()/@server.call_tool()+types.Tool(...)/bareTool(...)shape, and anyserver.jsonmanifest) and walks a static call-graph to label every finding reachable (inside a tool handler or a function transitively called from one), unreachable-by-tools (no call path from any registered tool), or unknown.reachableraises a finding's confidence;unreachablelowers it; a finding is never dropped on this basis (the over-flag philosophy stands). Stated limits: the same-file call-graph is exact; cross-file is best-effort by function name (not a resolved import graph); module-level code, non-Python (JS/TS/YAML/shell) findings, and repos with no discoverable tools are labelledunknownrather than guessed. It labels reachability; the separate taint pass (below) tracks the individual tainted value. The low-level-SDK discovery'sTool()-construction-to-dispatcher correlation is scoped to a single file/module (never a repo-wide guess across multiple dispatchers) and gated on the file actually importing something from themcppackage (so a same-named non-MCP class can't fliphas_toolsor claim a bogus root) -- round 1 of N-vote review caught a repo with more than one low-level dispatcher, or a coincidentalTool/call_toolname from an unrelated framework, downgrading a genuinely reachable finding to a lower-confidence grade; round 2 (Opus final-verify) caught two further shapes that still manufactured or leaked a bogus root -- a split declaration-module/dispatch-module layout (the list_tools fallback was rooting reachability at the metadata-only list_tools handler, since removed outright) and a repo mixing one genuinely ambiguous dispatcher with an unrelated valid one elsewhere (the repo-widehas_tools/have_py_handlerscheck was letting the valid root "unlock" confident grading for the un-rooted tool too). Fixed by treating an un-rootedpy-lowlevel-sdkregistration exactly like unresolved dynamic dispatch: withhold CLI_ONLY/UNCALLED in favor of UNKNOWN. All four shapes are regression-tested. Coverage gap closed (2026-07-23, later same day; N-vote fix pass same day after 2 refuters found live repros in the first cut). detector 5 (write-tools-on-by-default / tool-scope-creep) and detector 6 (secret-leak-via-tool-response) used to consume only decorator-style registration (JS-regex entries for scope-creep; a private@mcp.tool()walk for secret-leak) and produced zero findings for a repo using only the low-level SDK shape. Both now treat a provenance-gated@server.call_tool()handler as an inspection root via the sharedtool_registry.dispatch_segmentshelper: it splits the handler's body into per-tool branches from a top-levelif name == "x": ... elif name == "y": ...dispatch chain. Every link in the chain must compare the exact SAME discriminant expression (structural equality, not just "some string-equality test") -- the first cut accepted any<expr> == "literal"at each link regardless of what<expr>was, so a strayelif arguments.get("mode") == "delete_everything":fabricated a root for a tool name that was never registered; once one link's discriminant breaks, that link and everything after it in the chain fall back to whole-handler attribution, never partially trusted. When more than one top-levelifhas a string-equality test, the chain whose discriminant matches the handler's first parameter (namein the conventionalcall_tool(name, arguments)shape) is preferred, so an unrelated earlierifcan't be mistaken for the real dispatch root. A branch attributable to exactly one literal tool name is analyzed (mutating-sink/gate for scope-creep; leak-shaped-return for secret-leak) and attributed to that tool; a branch that isn't attributable (anin (...)test, the finalelse, a broken-chain link, or no dispatch shape at all -- a dict-keyed dispatch table /match/case) is still analyzed but attributed to the dispatch handler itself, never guessed at one tool -- the same never-guess-a-root discipline as the reachability fix above (verified end-to-end against rag-mcp's own real shape,if name != "search_knowledge": ... else: ...-- a!=guard isn't recognized as attributable dispatch, but the whole-handler fallback still fires on a leak/sink placed in the else-body, it just doesn't get per-tool attribution). Both detectors additionally follow one hop into a helper function a branch plainly delegates to (mirroringtool_scope_creep.py's pre-existing one-hop helper-delegation convention) -- scoped same-file-only: an unrelated, never-imported same-named helper elsewhere in the repo (e.g. a debug script's own_formatthat happens to dumpos.environ) is not followed, matching the same-file-onlyTool()<->dispatcher correlation precedent already shipped for the reachability fix above. Gate detection for tool-scope-creep's low-level path is per-branch, not whole-handler-text: a gate hint (e.g.is_authorized(...)) inside one branch does not silence an ungated sibling branch's own mutating sink in the same handler -- only a genuinely shared pre-dispatch check (statements before the if/elif chain, which run unconditionally for every branch) legitimately gates every branch; the handler's own decorator and any module-level env gate still apply as before. Known boundary, disclosed rather than silently left: only a literal==if/elif chain with a consistent discriminant counts as attributable dispatch (no real dataflow); a file with zero or 2+@server.call_tool()handlers is skipped entirely by both detectors (never guess a root) -- this means a repo with more than one dispatch handler in the same file gets NO detector-5/detector-6 coverage at all for that file, not a partial or lower-confidence one, exactly as disclosed for the reachability grading above. Named follow-up CLOSED (2026-07-23, later same day; two N-vote rounds). The PRE-EXISTING decorator-registered path intool_scope_creep.py(predates the low-level-SDK work above,_build_gate_index/func_indexbuilt repo-wide by short function name inrun()) carried the inverse bug from the per-branch fix above -- an N-vote refuter proved live that an unrelated, same-named gated helper elsewhere in the repo could silence an ungated decorator-registered mutating tool (a false NEGATIVE on this detector's primary target class, the worse direction than a false positive). Round 1 fixed this by moving the decorator path onto the same_build_function_index_for_filesame-file-only convention the low-level path already used. A second N-vote pass then proved round 1 over-corrected, with two further live repros: (a) same-file scoping silently severed the cross-file SINK hop too, not just the gate hop -- a non-mutating-named tool one-hop-delegating through an explicit import to a real, reachable, ungated sink in a separate file went to total silence (0 findings), the worst possible direction for this detector's primary target class; (b) the same-file gate index still unioned bare method names across DIFFERENT classes in one file -- an unrelated same-named gated method on another class could still silence a genuinely ungated one. Round 2 replaced same-file-only with a bounded, one-hop, IMPORT-AWARE resolver: an explicit, statically-resolvable same-repo import (import mod,from x.y import z, and relative equivalents including the real fleet's ownfrom .groups import writesubmodule-import shape) is followed for BOTH sink detection and gate credit -- exactly one target file per import statement, never a repo-wide guess; a same-file class-qualified call (ClassName().method(...)/self.method(...)from within that class) is cheaply disambiguated to the exact method; anything else falls back to the same-file bare-name heuristic, now with an explicit ambiguity rule (sink OR'd across same-named candidates, gate credit AND'd -- disagreement withholds credit rather than unioning a false-clean). Disclosed residual, honest and unavoidable: a hop that resolves to neither an explicit import nor a same-file candidate (e.g. a helper imported from a genuine third-party package) is a real miss for a non-mutating-named tool whose entire mutating behavior lives behind it -- gate not credited, sink not followed, over-flag stands only for tools whose name already looks mutating. Verified against the fleet's own real MCP servers both rounds (--self-audit, all five stay clean, no new P0/P1) -- every exposure found was synthetic-fixture-proven, never fleet-observed.clean_tool_scope(the original cross-file wrapper->write.py fixture) round-tripped through both fixes -- briefly over-flagged after round 1, correctly quiet again after round 2's import-aware resolver -- and its test was updated in place at each step rather than left to rot; new fixtures (clean_tool_scope_same_file_gate,vuln_tool_scope_cross_file_sink_import,vuln_tool_scope_same_file_class_collision,clean_tool_scope_groups_import,vuln_tool_scope_unresolvable_external_hop) pin each shape precisely. The low-level SDK dispatch path is NOT touched by round 2 and stays same-file-only -- a cleanly reusable follow-up (the same import-aware resolver would apply there too), not attempted here. - Sink classification: resolved, not syntax-shaped (fixed 2026-07-23, N-vote-hardened over two passes the same day). Detector 5's (tool-scope-creep)
_is_mutating_sink_callused to testif "subprocess" in name-- a bare substring match against the call's own dotted/bare name -- so a HELPER FUNCTION merely named_run_subprocesswas misclassified as a direct dangerous-sink call even though its body never touched the realsubprocessmodule. Reproduced live against vllm-ops-mcp:get_gpu_status/get_service_status/get_serve_configeach delegate throughprobes.pyto a helper literally named_run_subprocess, producing 3 false P1/HIGH findings. Round 1 replaced the substring test with exactmodule.attrdotted-path matching, gating the short-name fallback behind"." in name-- but two N-vote refuters proved live that this was itself a syntax-shape proxy standing in for "is this an attribute access", and broke on both sides: (P0) it blanket-excluded every bareNamecall, silencing REAL sinks reached via a direct stdlib import (from os import remove; remove(path),from shutil import rmtree,from subprocess import run) that base correctly caught; (P1)_dottedcollapses to the bare leaf whenever an attribute call's receiver isn't a plainName(Path(x).unlink(),requests.Session().post(url),get_proc().run(cmd)), so the "." gate silently missed those idiomatic sink shapes too. Round 2 replaced the proxy with RESOLUTION: a per-file_SinkFileCtx(module aliases, direct stdlib-sink imports, repo-internal function names) built from machinery this repo already owns (_build_import_map, the same-file function index), plus structuralisinstance(call.func, ast.Attribute)gating instead of testing the rendered string. A bare call now resolves to (a) a known stdlib-sink import -> sink, canonicalized to its real spelling; (b) a repo-internal function -> not a sink by itself, the one-hop resolver inspects its real body elsewhere (the original_run_subprocessfix, done correctly this time); (c) genuinely unresolvable -> over-flag-safe short-name fallback, restoring base's original catch. The exact-match set also gainedsubprocess.getoutput/getstatusoutputand dropped a deadsubprocess.popen(lowercase, no such callable) entry. Verified live (before/after--self-auditfleet sweep,MCP_SCANNER_FLEET_ROOTset to the 8-repo fleet, both rounds): the 3 vllm-ops-mcp findings go from 3×P1/HIGH to zero -- not a suppression this fix added, but an exposed side effect of an already-existing, already-disclosed limitation: vllm-ops-mcp's real chain is TWO hops deep (get_gpu_status_tool->probes.get_gpu_status->_run_subprocess->subprocess.run), and this detector's one-hop resolver only inspects the first resolved hop's own body. This two-hop-miss shape now has its own permanent regression fixture (clean_tool_scope_two_hop_probe_miss), closing a gap the round-1 pass had left as prose only. Separately, a severity/confidence calibration for the general ONE-hop-reachable case:subprocess.run/Popen/call/check_output/check_callinvoked WITHOUTshell=Truecalibrates to P2/MEDIUM instead of P1/HIGH; ashell=Truestring command stays P1/HIGH;os.system/os.popen/getoutput/getstatusoutputstay unconditionally high-risk (always shell-interpreted, no argv-list form exists) -- note the MEDIUM half is only visible for a finding reachability gradesunknown/unreachable: the scanner's pre-existing reachability pass raises MEDIUM to HIGH for any finding on a directly-reachable MCP tool (the common case), so in practice the calibration's user-visible signal is almost always the severity drop (P1 -> P2) alone. This calibration now resolves module aliases and bare stdlib imports too (import subprocess as sp; sp.run(cmd)withoutshell=Truecorrectly calibrates to P2, not an unconditional P1 -- round-2 fix). Seetool_scope_creep.py's module docstring ("Round 3" + the round-2 N-vote fix comment above_SinkFileCtx) andtests/test_tool_scope_creep_sink_substring_fix*.py(both files) for the full rationale, every refuter repro pinned as a permanent regression test, and all other pinned cases. Fleet sweep across all 8 repos, both rounds, confirms zero new noise elsewhere (desktop-mcp/rag-mcp's pre-existing single unrelated finding each, github-mcp/bus-mcp/discord-mcp/rails-mcp/mcp-factory unchanged). - Tool-parameter taint tracking v1 (built 2026-07-21; cross-file budget raised 2026-07-22). A second post-detector pass seeds every registered tool handler's parameters as taint sources and propagates them through assignments, f-strings/concat/
.format(), common containers, and same-repo function calls into the param-injection sinks (subprocess/os.system/eval/exec/pickle/yaml.load/HTTP-fetch/open). Each such finding is labelled tainted (a tool parameter provably reaches the sink), untainted (the sink is in tool-reachable code but fed a constant / other source), or unknown.taintedraises confidence;untaintedlowers it; a finding is never dropped (the over-flag philosophy stands — anuntaintedsink is still reported, just lower-confidence). Stated limits, honestly: same-file dataflow is transitive, and cross-file follows up to two direct-import hops (no third hop, no cross-repo flow); it is not sanitizer-aware (a validated/escaped value is still treated as tainted, by design); and it does not model dynamic dispatch (getattr/*args/**kwargsre-binding), decorator transforms, module-level code, or non-Python surfaces — all labelledunknownrather than guessed. Deeper (3+ hop) and cross-repo taint remain out of scope. - Static only. No dynamic analysis. Reachability and taint are inferred from the static call-graph above, not observed at runtime.
- Confidence is load-bearing.
lowfindings are "a human should glance at this," and produce false positives by design (e.g. a variable file path in a test file). Most are P2/P3 — but since the one-law demotions (fake-marker, test-cert, author-suppressed), aP1/LOWfinding is a real, intended shape:clean_billis severity-only, so a P1/LOW does break the product clean bill (see the dogfood table above — that's honest, not a bug); only the self-audit test's stricter "no HIGH/MEDIUM-confidence P0/P1" bar filters LOW. - Not a git-history scanner. The secret detector reads the tracked working tree, not full history. Pair it with
gitleaksfor history. - Language coverage (updated 2026-07-22). Deep for Python (AST-based). JS/TS has no AST path in this scanner -- it is line-based regex (shared helpers in
mcp_scanner/js_util.py), the same approachjob_hazards.pyalready used for its non-Python file types. Collected extensions (js_util.JS_SUFFIXES, shared byscanner.py's_SCAN_SUFFIXESandtool_registry):.js,.mjs,.cjs,.ts,.mts,.cts,.jsx,.tsx--.jsx/.tsxJSX syntax (attribute{...}braces,{cond && <X/>}conditional-render braces,{/* JSX comment */}) was verified, not assumed, not to trip the brace/string-aware helpers:tests/fixtures/vuln_tsx_dashboardmixes a real eval() sink, an ungated mutating tool, and a secret-named return field with a realistic JSX render block, and every finding lands on the sink line, none inside the JSX;clean_tsx_dashboardcarries the identical JSX shapes with safe code and stays fully quiet. Four detector families now have JS/TS parity on this regex basis: param-injection (exec/execSync always-shell -- includingnode:child_processand destructure-aliased bindings, e.g.const { exec: run } = require(...)-- spawn/execFile withshell:true,eval()/new Function(),yaml.loadwithout a safe schema, fetch/axios/http(s).get SSRF, fs read/write path-traversal), tool-scope-creep (mutatingserver.tool(...)registrations with no gate, via a capped line-window heuristic standing in for a real function-body scope; gate-hint matching is comment-stripped so a// TODO: needs auth_requirednote can't suppress a finding), secret-leak-via-tool-response (process.env/whole-config/secret-named/hardcoded-secret returned from a tool -- same-line compressed object literals and multi-line returns alike -- via the same window heuristic plus a string-literal-aware brace-depth tracker), and secret-handling's secret-in-log check (console.*/logger.*calls with a secret-named argument, word-boundary-guarded against a name that merely contains a secret-vocabulary substring; hardcoded-secret-value scanning was already language-agnostic). Not covered for JS/TS, by deliberate scope decision: codegen-injection (the mcp-factory class is inherently a Python-Jinja pattern) and auth-posture (bind/debug/mutating-route checks are inherently Flask/FastAPI-decorator-shaped; an Express/Fastify equivalent is new detector logic, not JS parity of the existing one -- left for a future increment, not attempted here). Jinja templates remain regex-level as before. - Known JS/TS regex-heuristic gaps (documented, not built -- 2026-07-22 adversarial review, both waves; extension gap closed 2026-07-22, see Language coverage above). Stated honestly rather than silently missed: optional-chaining eval (
globalThis?.eval?.()) isn't matched by theeval(sink regex; a spread-of-secret-variable return (return {...apiKey}) isn't decomposed the way a named key is, and neither is the equivalentreturn Object.assign({}, {apiKey: process.env.API_KEY})shape (same family -- a secret-named key wrapped in a call other than a plain object literal);tool_registry's JS registration regex is comment-blind, so a commented-out// server.tool('foo', ...)still registers a phantom tool -- an over-flag, the direction this scanner already accepts, not an under-flag; and the JS registration-window heuristic used by tool-scope-creep / secret-leak-via-tool-response is documented as "40 lines" but is actually 41 (startthroughstart + 40inclusive) -- a cosmetic off-by-one in the docstring, not a functional gap. - FP-class reduction, wave 1 + rounds 2-3 (2026-07-23). Driven by a one-time, read-only static scan of 13 popular public MCP server repos (official-org servers, frameworks, and single-purpose servers spanning Python and JS/TS, ~4.9k-88.8k GitHub stars, each with a commit in the prior ~3.5 months) -- hand-reviewed to separate genuine findings from scanner false positives. That scan's own artifacts are a local, gitignored working file (not part of this repo, and not re-fetchable by a reader), so the evidence that ships HERE, durably, is the regression fixture in each test file below: every fixture reproduces the REAL shape of the false positive (anonymized, never verbatim third-party source beyond the minimal triggering pattern) and is re-run on every
pytestinvocation, including in CI. Four evidenced false-positive classes, each fixed as a precision-only demotion that requires proof, never a guess -- two further N-vote passes (round 2, round 3) then found and closed adversarial gaps in the fixes THEMSELVES, converging on one law, stated once and applied everywhere, no per-case exceptions: full suppression is reserved for OUR OWN curated exact-match judgment; every other signal -- a target's suppress comment, an obviously-fake value marker, a cert's test-path/short-validity, a name-demotion with no independent value check -- may only demote confidence and tag the finding, it may never make it disappear. Round 2 found five gaps against this law; round 3's N-vote then found that round 2's OWN fixes had re-introduced three more special-cased exceptions to it (an unanchored fake-marker FULL-SUPPRESS, a cert-demotion OR-gate where validity alone was sufficient, and a keyword-blind regex-context heuristic that leaked real braces into the scope walker) plus one more shared-helper bypass:- Pagination/continuation-cursor field names (
next_token,page_token,cursor, ...) no longer trip the secret-name heuristic (secret-leak-via-tool-response,secret-in-log). Demotion (secret_handling.py's_is_pagination_cursor_name) requires BOTH the pagination word-shape (next/page/continuation combined with token/cursor, or a barecursor) AND the absence of any stronger credential word in the same identifier --access_token/refresh_token/client_secret/page_token_secretnever demote. Real example: awslabs/mcp'snext_tokenfield in a paginated tool response. Value-shape backstop: a name-demotion is never the ONLY signal --_SECRET_VALUE_PATTERNSgained JWT-shaped and Bearer-prefixed patterns, andsecret_leak_response.pyresolves a Name leaf to its own assigned literal (same-scope, simple assignments only) so a real JWT/bearer secret assigned to a pagination-demoted name still flags on VALUE shape alone. Round-3 fix on the backstop itself: round 2's guard against obviously-fake values (_is_real_secret_value_match) was an unanchored substring FULL-SUPPRESS --PROD_API_KEY = "sample-tier-<68 real hex chars>"(a real secret whose value merely contains "sample" as an unrelated tenant/tier component) vanished to zero findings, live-reproduced. Replaced with_has_fake_marker+_compose_demotion: a fake/dummy/placeholder/sample/demo/test marker, tokenized the same way identifier words are (so it matches insidegithub_pat_fake_test_token, which a naive\bregex never does -- underscore is a word character), now only demotes confidence to LOW and tags the title(fake-marker), applied uniformly to every value-shape pattern (no special-cased subset of labels), NEVER acontinue. - Self-signed TEST certificates at a test-fixture path (
tests/,fixtures/,testserver/, ...) no longer triptracked-secret-fileon extension alone. Demotion requires the test-path, a provable self-signed marker (issuer == subject, parsed via the optionalcryptographypackage -- see thecertsextra inpyproject.toml), AND the cert's own CN/SAN must match a localhost/test/example/demo/dummy/mock/fixture pattern -- self-signed + test-path alone discriminates nothing real (a genuine internal-CA production root is self-signed too, and integration suites routinely embed real staging/prod TLS material undertests/). Round-3 fix: round 2 wired the CN/SAN marker as one arm of an OR-gate with short (<=90-day) validity and a sub-2048-bit RSA key -- live-reproduced verbatim at the exact 90-day boundary (a prod-shaped CN, normal key, short validity) demoting to total invisibility. The CN/SAN marker is now REQUIRED; validity and key size are not independently sufficient (plenty of legitimately-short-lived real certs exist). Cert demotion is also no longer a silent break: a demoted cert (or its cryptographically-paired private key) still emits a finding -- LOW confidence, tagged(test-cert)-- it never vanishes with zero trace. A cert whose issuer differs, a self-signed cert outside a test path, a self-signed test-path cert with no CN/SAN marker, or any cert whencryptographyisn't installed all stay HIGH-confidence flagged (fails closed, never guesses). Real example: microsoft/playwright-mcp'stests/testserver/cert.pem+key.pem(CN=playwright-test). - Well-known placeholder credentials (documented SDK example literals, e.g. AWS's own
AKIAIOSFODNN7EXAMPLE/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYpair) fully suppresshardcoded-secretvia a curated exact-match set (secret_handling._KNOWN_PLACEHOLDER_SECRETS), never fuzzy/substring matching -- OUR OWN judgment, so a real secret that merely resembles a placeholder still flags. A maintainer's own suppress-convention comment (# pragma: allowlist secret, thedetect-secretsconvention) is a DIFFERENT, weaker signal (it's target-authored and attacker-controllable in an adversarial scan) -- it only demotes confidence and tags the finding "author-suppressed," it never fully suppresses, and it's evaluated per-MATCH (.finditer(), not.search()) so a real secret co-located on the same line as a suppressed placeholder still flags independently. Applied consistently in both the line-based literal scan and the ASTNAME = "literal"assignment branch. Real example: awslabs/mcp'sdynamodb-mcp-serverDUMMY_ACCESS_KEY. - Pre-existing dead-code fix, found during round-2 review, in scope because it falsified wave-1's own precision claims: the AST
NAME = "literal"hardcoded-secret-assignment branch's_PLACEHOLDERregex contained an empty alternative in its top-level group, making.match()unconditionally truthy for any input -- this branch has silently never fired, for anything, since 2026-07-13. Fixed (no empty alternative,.fullmatch()instead of partial.match(), the curated placeholder list and pragma-demotes convention both wired in properly). Round-3 fix: this branch was still routing its NAME check through the raw_SECRET_NAME.search()regex instead of the shared_name_looks_secrethelper, bypassing both the word-boundary glued-word guard AND the pagination-cursor-name exclusion (OUR OWN curated name-shape judgment, the same "may fully exclude" category as the placeholder list) -- 14 of awslabs/mcp's 209 revived-branch findings were pagination-named assignments (expected_response.next_token = '...') that should never have reached this branch. Now routes through_name_looks_secretlike every other name-based check in this module. - RegExp.exec() vs child_process.exec() (JS/TS).
param_injection.py's JS shell-injection check used to flag ANY.exec(/execSync(call in a file that importschild_processanywhere, regardless of receiver -- somyRegex.exec(str)in a file that also legitimately usesexecSyncelsewhere was misclassified as a shell-injection sink. Resolves the call's receiver via_js_bindings_by_scope/_resolve_receiver_kind: every RegExp-var and direct child_process-module binding is scoped to its own innermost enclosing{...}block (module-level declarations stay visible everywhere, matching real JS closures), and a receiver resolves to whichever binding's scope is smallest/most specific at the usage line -- real-JS-shadowing-correct (the first cut was file-wide with no scoping at all, so a RegExp declared in one function could mask a REAL child_process sink of the same name in a completely different function; both directions confirmed live and fixed). Round-3 fix on the scope walker itself: a/immediately after a KEYWORD (return /^{$/.test(x)-- "return" ends in an alnum char, not a symbol) was never recognized as a regex-literal opener by the original context heuristic, so its embedded{/}leaked into the brace-based scope walk as real code braces, corrupting/merging function spans and masking a realcp.exec(cmd)sink elsewhere in the same file (live-reproduced, 0 findings). Fixed two ways: (1) the regex-context heuristic now also recognizes a preceding keyword (return/typeof/case/in/of/delete/void/do/else/yield/await/instanceof/new/throw), not just a preceding symbol; (2) the scope walker now FAILS CLOSED -- if a file's braces don't balance at all (whether from a genuinely malformed file or a regex shape the heuristic still doesn't catch), EVERY binding in that file is discarded outright (not "treated as module-scope," which would be the wrong, mask-a-sink direction) and every.exec(receiver in it stays over-flagged, matching pre-scope-fix parity. A literalchild_processreceiver, an unresolvable receiver, or a bare/aliasedexec(...)call (no receiver at all) all stay flagged too. - The one law (stated once, no per-case exceptions): this scanner's use case is scanning THIRD-PARTY, possibly-adversarial repos, not auditing a cooperative owner's own code. Full suppression is reserved for OUR OWN curated exact-match judgment (the placeholder list, the pagination-cursor-name shape, the cert CN/SAN test-identity marker; plus two narrower, value-shape-safe cases for completeness: the AST name-branch's placeholder value-shape regex
_PLACEHOLDER-- double-covered, since the line-based value scan does not consult it, so it can never hide a value-shaped secret -- and the pre-existing.env.example/.sample/.template/.distfilename skip, which suppresses only the file-level tracked-secret-file finding while values inside stay content-scanned) -- every other signal may only demote confidence and tag the finding, it may never make a finding disappear. This includes a target's suppress comment, an obviously-fake value marker, and a cert's test-path/self-signed/short-validity shape. A direct, practical consequence: a fleet server whose OWN test fixtures embed an obviously-fake-named secret (e.g."Bearer github_pat_fake_test_token_1234",TEST_TOKEN = "fake-test-token-do-not-use") now correctly shows a LOW-confidence finding instead of zero --clean_bill(severity-only by design) reflects that honestly; the dogfood test suite's bar for "clean" is calibrated to tolerate LOW-confidence-only noise rather than re-suppressing it (seetests/test_self_audit.py). - Test-path confidence demotion (wave 4, CLOSED 2026-07-23; round-2 safety fix same day). Reviving the dead AST-assignment branch (above) surfaced a substantially larger finding volume on test-heavy repos -- one real-world ecosystem clone went from 5 to ~196-209
hardcoded-secretfindings, almost entirely mock/test credential assignments in test files (mock_credentials.token = "...",provider._password = "test..."). Wave 4 closes this noise class the same way every other FP class here is handled: ahardcoded-secretfinding demotes to LOW confidence and is tagged(test-path)-- never dropped, suppressed, orcontinue'd. Round-2 fix (refuter-B P1): the first cut demoted on the bare test-fixture PATH alone, which dropped a genuine value-shaped secret (AKIA.../ghp_.../sk-.../JWT/private-key) to LOW purely because it sat under a segment likefixtures/,spec/,mocks/,testserver/, ortestdata/-- all of which are production-plausible (Django/Railsfixtures/seed prod DBs vialoaddata;spec/holds OpenAPI/protobuf/JSON-schema specs;mocks/ships as a runtime MSW feature; Go'stestdata/VCR cassettes have a real-world precedent for containing ACTUAL recorded prod credentials). A bare path name is target-controllable and discriminates nothing real -- weaker than the cert-path precedent (README above), which demotes only on multiple independent corroborating signals (self-signed AND test-path AND CN/SAN marker). The corroboration rule now:test-pathis a PAIR-ONLY signal in_compose_demotion-- it never demotes on its own. It demotes only when paired with a corroborating co-signal beyond the bare path, or merely tags a finding already demoted by a standalone signal. Concretely: (1) the value-shape branch (AKIA.../ghp_.../-----BEGIN PRIVATE KEY-----/sk-.../JWT/Bearer ...) demotes ONLY on a standalone signal -- an authorpragma(target-controllable but explicit) or afake-markerin the value itself (self-limiting: a value literally containingxxxx/fake/exampleas a whole word cannot also be a working credential); a bare test path merely appends its tag to an already-demoted finding, so a real value-shaped secret with no other signal keeps its base HIGH confidence in ANY directory (the hard invariant); (2) the weaker AST-name branch (base MEDIUM) demotes when a mock/fake-shaped assignment NAME (mock_credentials.token,fake_provider._password) pairs with a test path -- two signals -- which preserves the original noise-reduction goal; this is safe for a real secret because a value-shaped value is independently caught HIGH by the value-shape branch (a name can never pull it down), and this branch only demotes the weaker name-based duplicate whose value is an arbitrary string. Safety-vs-noise tradeoff (safety wins): a realistic-looking secret value intestdata/that is NOT self-evidently fake and NOT mock-named now stays HIGH -- some noise reduction is traded away rather than risk demoting a real leak on path alone. Regression-pinned intests/test_secret_testpath_demotion.py(mock/fake-named assignment demotes; a realAKIA/ghp_/private-key value with no other signal STAYS HIGH undertests/,fixtures/,spec/,mocks/,testserver/,testdata/; a fake-VALUED secret still demotes; outside-tests confidence unchanged; tag composition never yields zero findings). Fleet--self-auditbefore/after: identical totals (nothing real lost visibility). - Grading honesty + JS/TS precision, wave 5 (2026-07-29). (Historical record of that wave. For the current baseline, skip to wave 6 below.) A held-out measurement scanned five pinned third-party MCP servers (notion-mcp-server, mcp-server-neon, mcp-server-qdrant, firecrawl-mcp-server, airtable-mcp-server) and scored 0 true positives / 58 findings. All 58 came from the four JS/TS targets; the one Python target returned a clean bill. Root cause:
reachability.pyandtaint.pyboth return UNKNOWN unless the file is.py/.pyw, so every JS/TS finding was raw regex output with no precision layer -- and the report rendered it identically to a call-graph-proven Python finding. Same severity badge, same confidence, no visible difference. That is a REPORTING defect as much as an analysis one, and it is the half fixed here. This wave does not add a JS/TS call-graph -- it makes the output honest about not having one.- The
gradeaxis (grading.py,models.Grade). A finding whose reachability AND taint both came back UNKNOWN is labelledUNGRADEDwith a stated, specific reason. The label is deliberately outcome-based and language-agnostic so it cannot go stale as surfaces are added. Surfaced in every renderer: a badge and a "Not graded because" line in the terse markdown, a Graded? column plus legend in the client report, agrade/grade_reasonfield in the JSON, a badge in the generated MD/HTML report, and an ungraded count on the summary line. Scan JSON written before the axis existed defaults togradedand is never retro-labelled. - Per-run grading coverage.
ScanResult.coveragerecords how many files the precision passes could and could not analyse, broken down by reason, and every markdown renderer prints it. A clean bill over an ungradable surface is qualified in the same callout -- the mirror-image failure is a false assurance, and burying that caveat lower in the report is how it happens. - A scoped severity cap. An ungraded finding in a DATAFLOW class (shell-injection, code-eval, unsafe-deserialization, ssrf, path-traversal -- imported from
taintby identity, not copied) whose FILE could not be analysed at all is capped at P2/LOW. For those classes the severity ladder is itself a dataflow claim ("P0 = exploitable now" asserts caller-controlled data reaches the sink), so without a dataflow pass P0 is unearned. The cap keys on whether the file was ANALYSABLE, never on whether an answer was reached -- reachability and taint also return UNKNOWN when they ran and deliberately abstained (module-level code, dynamic dispatch, un-rooted low-level dispatcher, no tool roots), and the round-3 contract requires those to leave confidence untouched. The cap is out of scope for every non-dataflow class: a committed AWS key is a P1 whether or not a tool reaches it, so cappinghardcoded-secretwould be dishonest in the other direction. Both directions pinned. redis.eval()is not JavaScripteval(). The only four P0s in the whole sample wereredis.eval(LUA_SCRIPT, {keys, arguments})in neon'smcp/oauth/refresh-lock.ts-- correctly parameterized Redis server-side Lua.\beval\s*\(matched after a.; now(?<![\w$.])eval\s*\(, the same lookbehind_JS_BARE_EXECalready used forexec. Stated false negative:window.eval(...)no longer matches either, because re-admitting anyX.eval(re-admitsredis.eval(.- One shared test-path classifier (
test_paths.py), extended to FILENAME markers. The only classifier in the repo matched directory segments, sosrc/e2e.test.ts(the dominant JS/TS/Go convention) was invisible -- 7 findings. Now recognises*.test.ts,*.spec.js,*_test.go,test_*.py,conftest.py, with every marker requiring a real separator on its leading edge (latest/,protest/,attest.ts,testimonials.ts,specification/all stay out).secret_handling._is_test_fixture_pathis now an alias BOUND TO it, pinned by a test, so a second copy cannot drift in. - A test harness is not a tool entrypoint.
Reachability.CLI_ONLYalready meant "every caller traces to a non-tool entrypoint (argv/CLI-main, a test file, an admin script)"; a test/spec path now selects it on ANY surface, bringing the existing confidence nudge and--fail-onexclusion with it. Strictly a fallback below the call-graph -- it fires only where the AST pass returned UNKNOWN, so path shape (the weakest evidence here) can never override a proven REACHABLE grade. - Non-credential SHAPE as the missing pair partner. 13 findings were the weak
Bearervalue pattern matching synthetic fixtures (Bearer fco_other_resource_token,Bearer fc-invalid-credential).Bearermatches any 20+ char run, an English word-phrase included._is_non_credential_shape(separator-joined, all lowercase, every segment alphabetic and <=15 chars, >=2 segments) is offered to_compose_demotionas a path co-signal -- so it demotes only PAIRED with a test path, never alone, and a real high-entropy value undertests/still keeps HIGH. Applied uniformly to all seven value patterns rather than special-cased; inert for the other six by construction. - The redaction is now honest. The measurement's stated aggravating factor was that these were redacted to a bare
<redacted secret line>, so a reader could not tell a fixture from a live credential without opening the repo. The snippet now carries the demotion tags --<redacted secret line (non-credential-shape, test-path)>. The value itself is still never printed. - A logged member chain is judged by its TERMINAL segment.
logger.info('OAuth token found', { clientId: token.client.id })logs a public client id; it fired because the chain's ROOT (token) matched. Nowtoken.client.idis judged onid. One curated recovery: a GENERIC VALUE ACCESSOR terminal (value/raw/plain/...) carries no information, so the parent is judged instead, keepingsecret.valueandtoken.rawfiring.idis deliberately not in that set. Applied to the Python AST path and the JS line-based path in the same commit, sharing one decision helper -- the identical bug existed in both, and a carve-out present on one surface and not the other is worse than none. rm -rfon curated build artifacts demotes to P3. airtable'sbuild-mcpb.shdeletingnode_modulesand its own.mcpboutput is a build step, not an irreversible-ops hazard. Requires EVERY target on the line to be a curated exact-match name or archive suffix; fails closed on a variable, a glob, an absolute or home-relative path, or one unrecognised entry among several (rm -rf node_modules /etc/nginxkeeps P1). Demoted and tagged, never dropped.- Client-report framing. Graded findings now lead "Top 3 to fix" within a severity tier (severity still dominates), the executive summary states the ungraded share, and the "each critical includes a reproducible proof" claim is only made when criticals exist.
- Explicitly DECLINED, and why. (a) Detecting an adjacent guard -- neon's
validateDocSlug(), firecrawl'sencodeURIComponent(), notion'sredactToken()all defend the flagged line, and recognising that requires real JS/TS dataflow, i.e. the JS call-graph this wave deliberately does not build. TheUNGRADEDreason string names this limitation verbatim ("a validator or sanitiser adjacent to this line would NOT have been seen"). (b) A redaction-wrapper suppressor -- a wrapper NAME is target-controlled (a hostile repo can name a passthroughredactToken), so per the one law it is surfaced as report context, never a silencer. (c)|| truein a build script (build-mcpb.sh:19) -- masking an exit status is a genuine, if minor, reliability finding and job-hazards exists for exactly that surface; kept at P2. - Re-measured on the same five pinned commits: 58 -> 53 findings, 4 P0 -> 0 P0, 42 of 53 now carry a real grade. Honest bottom line AT THE CLOSE OF THIS WAVE: true positives were 0/53. A smaller denominator is not a capability gain, and this wave is not presented as one -- what changed is that a reader can now see which findings rest on evidence and which are pattern matches, and the four P0s that would have led an outreach email are gone. That 0/53 is a historical figure, superseded the next day -- see wave 6 below for the current baseline (88 findings / 2 hand-audited true positives, 1 of real-world consequence). It is preserved here because the before/after is the point, not because it describes the scanner today.
- The
- Known perf bound, disclosed not fixed this round:
_sibling_self_signed_cert's directory scan (the paired-key demotion check) is O(n) per key file in a directory with n cert/key files, making a directory with many pairs O(n^2) overall -- measured ~4.15s for 100 pairs in one directory, ~33.3s for 400. Cert parsing itself is memoized (_parse_x509_cert_bytes_cached), so this is redirectory-iteration overhead, not repeated crypto work; a real-world repo with hundreds of PEM pairs in a single directory is an unusual shape, but the bound is real and worth stating rather than silently living with. Not fixed this round (no functional-correctness impact). - Every demotion above has a paired regression test proving a REAL secret/exec in the same shape still flags -- including every adversarial repro named above (shadowed-scope RegExp both directions, a pragma-commented real secret, a self-signed cert with a prod-shaped identity, a JWT value under a demoted name, a keyword-preceded regex literal masking a real sink, an unbalanced-brace file, a pagination-named assignment through the AST branch, and every round-3 "must demote, never disappear" case) -- see
tests/test_secret_pagination_fp.py,tests/test_secret_selfsigned_cert_fp.py,tests/test_secret_placeholder_fp.py,tests/test_secret_placeholder_regex_ast_branch_fix.py,tests/test_param_injection_regexp_exec_fp.py,tests/test_param_injection_regexp_scope_fp.py,tests/test_round3_onelaw_fixes.py. - Recall fixes, wave 6 (2026-07-30). Current baseline: 88 findings / 2 hand-audited true positives -- of which 1 is of real-world consequence (see the caveats below; the honest headline number is 1). Wave 5 established that the scanner reported nothing real on five held-out third-party servers. The root cause turned out to be tool-registry extraction, not the missing JS/TS precision layer --
tools_detectedwas 0 on four of the five targets and 1-of-34 on the fifth, because the registration regex matched only the DEPRECATEDserver.tool()API. Every target registering tools through a current or non-decorator idiom was invisible --server.registerTool(name, config, handler)(airtable, 16 real tools),server.addTool({...})(firecrawl, 27), aforEachloop over a tool list (neon, 34), a FastMCP subclass callingself.tool(func, name=...)(qdrant, 2), and the low-levelsetRequestHandler(CallToolRequestSchema, ...)shape (notion) -- so every reachability grade downstream was rooted at nothing. Three slices fixed it: registry extraction for all five real idioms, plus un-gatingauth_posture's andsecret_handling's text-shaped checks from the Python AST so they run on.tsat all. Result: 53 -> 88 findings (notion 11, neon 10, qdrant 0, firecrawl 35, airtable 32), and 0 -> 2 findings that survive a hand audit. Tool registrations are now found on all five targets where previously none were -- but that boolean flatters the reality and is not the number to read: extraction is now exact on three targets (airtable 16/16, firecrawl 27/27, qdrant 2/2) and still a near-total miss on two (neon finds 4 of 34, notion 1 of ~24, and every one of those five is an unnamed(inline)registration where the regex matched the call but could not capture a name). Anything downstream that keys off the tool registry remains effectively blind on neon and notion. Read that increase honestly: most of the extra 35 findings are NOT true positives -- 9 tool-scope-creep entries on airtable's mutating tools, 14 synthetic fixture credentials in test files, and assorted P3 repo-level posture findings, all graded or demoted appropriately and kept out of the default--fail-ongate. Both are in airtable-mcp-server, and they are not of equal weight -- read the second one's caveat before quoting "2 true positives" anywhere:src/main.ts:54-- the genuine one.app.listen(port, callback)with no host argument binds all interfaces, serving POST/mcpwith no auth middleware, no Origin validation and no DNS-rebinding protection, which contradicts the MCP spec's localhost-bind + Origin-validation requirement. Caught by argument SHAPE: there is no0.0.0.0string anywhere in that file, which is exactly why a string-matching pass missed it. In fairness to upstream: this is opt-in (MCP_TRANSPORT=http; the default transport is stdio) and upstream already documents it --main.ts:56prints a "HTTP transport has no authentication" warning and their README says so. It is a known accepted trade-off, not a hidden bug this scanner uncovered.src/e2e.test.ts:23-- technically a true positive, and we are not going to oversell it. The value is a genuine live-format Airtable PAT by shape, and the scanner correctly graded it cli-only and correctly kept it out of the default gate. But the two lines immediately above it are an upstream comment stating it is a deliberately committed read-only token scoped to a dummy table of public test data -- the scanner strips comments, so it cannot see that. Calling this a security finding would be literally true and materially misleading. Counted here for arithmetic honesty; if you want the number that describes real-world value, it is 1, not 2.--fail-on P1against airtable now gates on exactly one finding. What this wave did NOT do: build the JS/TS precision layer. These were recall fixes, not precision fixes. 2/88 is a low precision figure and is presented as one -- the honest summary is that the scanner now finds real issues it previously could not see at all, while still over-reporting heavily on JS/TS. Every number above is reproducible fromecoscan-targets.lock.json, which pins each target's URL and commit SHA alongside the scanner SHA the measurement ran at.
- Pagination/continuation-cursor field names (
Tests
python -m pytest -q # 657 tests (648 passing, 9 self-audit skip without the env var below); the no-crypto figure previously carried here (403 passing / 11 skipped) is STALE as of the 2026-07-29 grading-honesty wave and has NOT been re-measured -- treat it as unknown until someone runs a genuinely clean venv without cryptography, rather than trusting the carried-forward number: per-detector vuln/clean fixtures (Python + JS/TS parity across .js/.mjs/.cjs/.ts/.mts/.cts/.jsx/.tsx) + the reachability-grading matrix (incl. the cli-only/uncalled decidable-reachability grades + the low-level MCP SDK Server()/list_tools/call_tool discovery shape, per-module-scoped and import-provenance-gated so a repo with more than one dispatcher, or a same-named non-MCP class, can't claim a bogus root, and an un-rooted low-level tool -- split declaration/dispatch modules, or a genuinely ambiguous multi-dispatcher file -- withholds CLI_ONLY/UNCALLED in favor of UNKNOWN the same way unresolvable dynamic dispatch does) + detector 5 (tool-scope-creep) and detector 6 (secret-leak-via-tool-response) low-level-SDK dispatch-branch attribution (2026-07-23: `tool_registry.dispatch_segments`, shared by both detectors) + the tool-parameter taint-tracking matrix (intra-file + cross-file, up to two hops) + the self-audit proof (now guarding 6 fleet servers directly, 8 total via FLEET_SERVERS) + client-report renderer + the CI README count-verification gate's own unit tests + wave-1 FP-class regression fixtures (pagination-cursor names, self-signed test certs, known-placeholder secrets, RegExp-vs-child_process .exec() receiver resolution) + the test-path confidence demotion (wave-4: pair-only, never demotes a real value-shaped secret on a bare path) + the `mcp-scan report` client-report generator (2026-07-23: stable line-independent finding_id + collision suffixes, scan_meta embedding, triage.toml verdict joins incl. the unknown-id loud-warning path, byte-stable golden HTML/MD renders, the zero-external-URL self-containment gate, and the no-hardcoded-counts template AST-grep gate) + the `mcp-scan ecosystem-scan` repeatable v2 pipeline (2026-07-23: batch-scan a fleet of MCP-server repos read-only -- mtime/bytes unchanged on every target, clone path injected/mocked so the suite makes zero real network calls, above-LOW findings gated fail-closed as disclosure candidates, PRIVATE-marked disclosure notes that surface only the target's own SECURITY.md and never invent a contact channel or auto-publish, anonymized aggregate + notes staged to gitignored local dirs, and a runtime-unique sentinel in the leak test so no fixture string can coincidentally match real output) + the destructive-action confirm-gate detector's FP-wave2 hardening (2026-07-23: recognizes a real in-body control-flow confirmation gate -- a negated force/yes/confirm/proceed param bound to an actual throw/exit/raise -- as equivalent to the SDK's -Confirm/--dry-run flag, conservative-by-design so a bare param reference alone never suppresses; an adjudication pass then removed an overly loose bare-phrase alternation with zero binding to any real gate; and the destructiveHint annotation doctrine -- a target's self-declared destructiveHint:true is recorded as context only and never suppresses or downgrades a genuine finding)
CI (.github/workflows/ci.yml) runs this suite on every push/PR and fails the
build if this claimed count drifts from what the suite actually reports --
see scripts/check_readme_counts.py.
The self-audit tests (9 of the 604) require MCP_SCANNER_FLEET_ROOT to be set
and pointed at real MCP server repos to scan; they skip cleanly if it's
unset (e.g. in a fresh clone or CI on another machine). See
ANNOUNCEMENT.md for the reproducible self-audit output.
Each detector ships a matched pair of fixtures — a vulnerable one it must catch, a clean one it must not flag — so the false-positive floor is a tested invariant, not a hope.
Metadata
Release files for jaimenbell-mcp-security-scanner 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| jaimenbell_mcp_security_scanner-0.3.1.tar.gz | 325.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| jaimenbell_mcp_security_scanner-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 517.4 kB
Release files / jaimenbell_mcp_security_scanner-0.3.1.tar.gz
| Download URL | jaimenbell_mcp_security_scanner-0.3.1.tar.gz |
|---|---|
| Size | 325.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8a7885163a7c081e2a818de22b54a4bf346c49c0ef61905677f0bab9c0c29d1f
|
|
BLAKE2b-256 checksum How to use checksums |
7c59f6fd76828ea3f7066b41067a4c4b0522b34a3728c06acb41512f151a0015
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.10
|
Release files / jaimenbell_mcp_security_scanner-0.3.1-py3-none-any.whl
| Download URL | jaimenbell_mcp_security_scanner-0.3.1-py3-none-any.whl |
|---|---|
| Size | 191.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
49720d2f4b9787303ad60280d571e8f540bc7e2d08cc884f0a63f30640f7bc0a
|
|
BLAKE2b-256 checksum How to use checksums |
0b0c81800d1cfd801a0e8d64c0b5f0403b472e54b4f44e6714e85de30159c3b8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.10
|