Skip to main content

mcp-security-scanner

CI

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-scanner There is an unrelated project on PyPI under the plain name mcp-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 as jaimenbell-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's Server() + @server.list_tools()/@server.call_tool() + types.Tool(...)/bare Tool(...) shape, and any server.json manifest) 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. reachable raises a finding's confidence; unreachable lowers 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 labelled unknown rather than guessed. It labels reachability; the separate taint pass (below) tracks the individual tainted value. The low-level-SDK discovery's Tool()-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 the mcp package (so a same-named non-MCP class can't flip has_tools or claim a bogus root) -- round 1 of N-vote review caught a repo with more than one low-level dispatcher, or a coincidental Tool/call_tool name 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-wide has_tools/have_py_handlers check was letting the valid root "unlock" confident grading for the un-rooted tool too). Fixed by treating an un-rooted py-lowlevel-sdk registration 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 shared tool_registry.dispatch_segments helper: it splits the handler's body into per-tool branches from a top-level if 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 stray elif 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-level if has a string-equality test, the chain whose discriminant matches the handler's first parameter (name in the conventional call_tool(name, arguments) shape) is preferred, so an unrelated earlier if can'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 (an in (...) test, the final else, 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 (mirroring tool_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 _format that happens to dump os.environ) is not followed, matching the same-file-only Tool()<->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 in tool_scope_creep.py (predates the low-level-SDK work above, _build_gate_index/func_index built repo-wide by short function name in run()) 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_file same-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 own from .groups import write submodule-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_call used to test if "subprocess" in name -- a bare substring match against the call's own dotted/bare name -- so a HELPER FUNCTION merely named _run_subprocess was misclassified as a direct dangerous-sink call even though its body never touched the real subprocess module. Reproduced live against vllm-ops-mcp: get_gpu_status/get_service_status/get_serve_config each delegate through probes.py to a helper literally named _run_subprocess, producing 3 false P1/HIGH findings. Round 1 replaced the substring test with exact module.attr dotted-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 bare Name call, 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) _dotted collapses to the bare leaf whenever an attribute call's receiver isn't a plain Name (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 structural isinstance(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_subprocess fix, done correctly this time); (c) genuinely unresolvable -> over-flag-safe short-name fallback, restoring base's original catch. The exact-match set also gained subprocess.getoutput/getstatusoutput and dropped a dead subprocess.popen (lowercase, no such callable) entry. Verified live (before/after --self-audit fleet sweep, MCP_SCANNER_FLEET_ROOT set 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_call invoked WITHOUT shell=True calibrates to P2/MEDIUM instead of P1/HIGH; a shell=True string command stays P1/HIGH; os.system/os.popen/getoutput/getstatusoutput stay unconditionally high-risk (always shell-interpreted, no argv-list form exists) -- note the MEDIUM half is only visible for a finding reachability grades unknown/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) without shell=True correctly calibrates to P2, not an unconditional P1 -- round-2 fix). See tool_scope_creep.py's module docstring ("Round 3" + the round-2 N-vote fix comment above _SinkFileCtx) and tests/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. tainted raises confidence; untainted lowers it; a finding is never dropped (the over-flag philosophy stands — an untainted sink 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 / **kwargs re-binding), decorator transforms, module-level code, or non-Python surfaces — all labelled unknown rather 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. low findings 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), a P1/LOW finding is a real, intended shape: clean_bill is 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 gitleaks for 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 approach job_hazards.py already used for its non-Python file types. Collected extensions (js_util.JS_SUFFIXES, shared by scanner.py's _SCAN_SUFFIXES and tool_registry): .js, .mjs, .cjs, .ts, .mts, .cts, .jsx, .tsx -- .jsx/.tsx JSX 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_dashboard mixes 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_dashboard carries 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 -- including node:child_process and destructure-aliased bindings, e.g. const { exec: run } = require(...) -- spawn/execFile with shell:true, eval()/new Function(), yaml.load without a safe schema, fetch/axios/http(s).get SSRF, fs read/write path-traversal), tool-scope-creep (mutating server.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_required note 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 the eval( sink regex; a spread-of-secret-variable return (return {...apiKey}) isn't decomposed the way a named key is, and neither is the equivalent return 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 (start through start + 40 inclusive) -- 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 pytest invocation, 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 bare cursor) AND the absence of any stronger credential word in the same identifier -- access_token/refresh_token/client_secret/page_token_secret never demote. Real example: awslabs/mcp's next_token field in a paginated tool response. Value-shape backstop: a name-demotion is never the ONLY signal -- _SECRET_VALUE_PATTERNS gained JWT-shaped and Bearer-prefixed patterns, and secret_leak_response.py resolves 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 inside github_pat_fake_test_token, which a naive \b regex 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 a continue.
    • Self-signed TEST certificates at a test-fixture path (tests/, fixtures/, testserver/, ...) no longer trip tracked-secret-file on extension alone. Demotion requires the test-path, a provable self-signed marker (issuer == subject, parsed via the optional cryptography package -- see the certs extra in pyproject.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 under tests/). 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 when cryptography isn't installed all stay HIGH-confidence flagged (fails closed, never guesses). Real example: microsoft/playwright-mcp's tests/testserver/cert.pem + key.pem (CN=playwright-test).
    • Well-known placeholder credentials (documented SDK example literals, e.g. AWS's own AKIAIOSFODNN7EXAMPLE/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY pair) fully suppress hardcoded-secret via 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, the detect-secrets convention) 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 AST NAME = "literal" assignment branch. Real example: awslabs/mcp's dynamodb-mcp-server DUMMY_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 _PLACEHOLDER regex 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_secret helper, 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_secret like 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 imports child_process anywhere, regardless of receiver -- so myRegex.exec(str) in a file that also legitimately uses execSync elsewhere 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 real cp.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 literal child_process receiver, an unresolvable receiver, or a bare/aliased exec(...) 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/.dist filename 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 (see tests/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-secret findings, 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: a hardcoded-secret finding demotes to LOW confidence and is tagged (test-path) -- never dropped, suppressed, or continue'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 like fixtures/, spec/, mocks/, testserver/, or testdata/ -- all of which are production-plausible (Django/Rails fixtures/ seed prod DBs via loaddata; spec/ holds OpenAPI/protobuf/JSON-schema specs; mocks/ ships as a runtime MSW feature; Go's testdata/ 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-path is 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 author pragma (target-controllable but explicit) or a fake-marker in the value itself (self-limiting: a value literally containing xxxx/fake/example as 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 in testdata/ 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 in tests/test_secret_testpath_demotion.py (mock/fake-named assignment demotes; a real AKIA/ghp_/private-key value with no other signal STAYS HIGH under tests/, fixtures/, spec/, mocks/, testserver/, testdata/; a fake-VALUED secret still demotes; outside-tests confidence unchanged; tag composition never yields zero findings). Fleet --self-audit before/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.py and taint.py both 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 grade axis (grading.py, models.Grade). A finding whose reachability AND taint both came back UNKNOWN is labelled UNGRADED with 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, a grade/grade_reason field 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 to graded and is never retro-labelled.
      • Per-run grading coverage. ScanResult.coverage records 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 taint by 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 capping hardcoded-secret would be dishonest in the other direction. Both directions pinned.
      • redis.eval() is not JavaScript eval(). The only four P0s in the whole sample were redis.eval(LUA_SCRIPT, {keys, arguments}) in neon's mcp/oauth/refresh-lock.ts -- correctly parameterized Redis server-side Lua. \beval\s*\( matched after a .; now (?<![\w$.])eval\s*\(, the same lookbehind _JS_BARE_EXEC already used for exec. Stated false negative: window.eval(...) no longer matches either, because re-admitting any X.eval( re-admits redis.eval(.
      • One shared test-path classifier (test_paths.py), extended to FILENAME markers. The only classifier in the repo matched directory segments, so src/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_path is 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_ONLY already 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-on exclusion 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 Bearer value pattern matching synthetic fixtures (Bearer fco_other_resource_token, Bearer fc-invalid-credential). Bearer matches 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_demotion as a path co-signal -- so it demotes only PAIRED with a test path, never alone, and a real high-entropy value under tests/ 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. Now token.client.id is judged on id. One curated recovery: a GENERIC VALUE ACCESSOR terminal (value/raw/plain/...) carries no information, so the parent is judged instead, keeping secret.value and token.raw firing. id is 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 -rf on curated build artifacts demotes to P3. airtable's build-mcpb.sh deleting node_modules and its own .mcpb output 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/nginx keeps 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's encodeURIComponent(), notion's redactToken() 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. The UNGRADED reason 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 passthrough redactToken), so per the one law it is surfaced as report context, never a silencer. (c) || true in 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.
    • 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_detected was 0 on four of the five targets and 1-of-34 on the fifth, because the registration regex matched only the DEPRECATED server.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), a forEach loop over a tool list (neon, 34), a FastMCP subclass calling self.tool(func, name=...) (qdrant, 2), and the low-level setRequestHandler(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-gating auth_posture's and secret_handling's text-shaped checks from the Python AST so they run on .ts at 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-on gate. 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 /mcp with 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 no 0.0.0.0 string 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:56 prints 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 P1 against 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 from ecoscan-targets.lock.json, which pins each target's URL and commit SHA alongside the scanner SHA the measurement ran at.

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

jaimenbell_mcp_security_scanner-0.3.1.tar.gz (325.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

jaimenbell_mcp_security_scanner-0.3.1-py3-none-any.whl (191.9 kB view details)

Uploaded Python 3

File details

Details for the file jaimenbell_mcp_security_scanner-0.3.1.tar.gz.

File metadata

File hashes

Hashes for jaimenbell_mcp_security_scanner-0.3.1.tar.gz
Algorithm Hash digest
SHA256 8a7885163a7c081e2a818de22b54a4bf346c49c0ef61905677f0bab9c0c29d1f
MD5 ab07c2af372d33d7a5fc7c4739d2b0a6
BLAKE2b-256 7c59f6fd76828ea3f7066b41067a4c4b0522b34a3728c06acb41512f151a0015

See more details on using hashes here.

File details

Details for the file jaimenbell_mcp_security_scanner-0.3.1-py3-none-any.whl.

File metadata

File hashes

Hashes for jaimenbell_mcp_security_scanner-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 49720d2f4b9787303ad60280d571e8f540bc7e2d08cc884f0a63f30640f7bc0a
MD5 f3d81eb331eda95b1c2fa6010a94f4ef
BLAKE2b-256 0b0c81800d1cfd801a0e8d64c0b5f0403b472e54b4f44e6714e85de30159c3b8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page