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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jaimenbell_mcp_security_scanner-0.3.0.tar.gz.
File metadata
- Download URL: jaimenbell_mcp_security_scanner-0.3.0.tar.gz
- Upload date:
- Size: 324.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7b43b933746b2732e7f4df412706c98364d9888ceba9eea921bc8cece0623d7c
|
|
| MD5 |
1905802fa1429e5ee4b25e9669f82aa9
|
|
| BLAKE2b-256 |
80d4884957beac5e85f92484e8cc8ac5aa7a8f44cab6700ed1d683745c811733
|
File details
Details for the file jaimenbell_mcp_security_scanner-0.3.0-py3-none-any.whl.
File metadata
- Download URL: jaimenbell_mcp_security_scanner-0.3.0-py3-none-any.whl
- Upload date:
- Size: 191.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
daa68b48be992f3c793c29d69537ee864f442b01e01cb5f8edc47fc5b3df37da
|
|
| MD5 |
b1f88af8e19226a076ede92cff9fd5cc
|
|
| BLAKE2b-256 |
0475be530f7ee4aa4e16301300d1d2a0e8857ce3b7a599b7382acf78d77a4a0a
|