Skip to content

Releases: DINAKAR-S/Agentic-MCP-Scanner

mcpvuln 0.3.0

Choose a tag to compare

@DINAKAR-S DINAKAR-S released this 05 Sep 12:56

The headline: fourteen new rules for the MCP vulnerability classes disclosed as CVEs
during 2025 and 2026, calibrated on 55 third-party MCP servers (840,905 lines) chosen
for being obscure rather than popular, and a second public fixture corpus that scores
them. The paper's 22-instance corpus is untouched and still scores exactly as published:
precision 1.000, recall 0.864, F1 0.927.

Detection

  • SSRF from a tool's URL argument (web.ssrf.tool_argument_url), the shape of
    CVE-2025-65513, CVE-2026-26118, CVE-2026-27826 and TRA-2025-36. The rule runs only in
    files that define MCP tools and show no private-address or allow-list check.
  • Argument injection into git, kubectl, docker, ssh and similar
    (web.argument_injection.cli_list), the shape of CVE-2025-68144 in Anthropic's
    mcp-server-git. An argument list that passes "--" before the caller's values is exempt.
  • Prefix-only path containment (web.path_traversal.startswith_containment),
    CVE-2025-66689 and CVE-2025-53109/53110: .startswith(root) in a file that never
    canonicalises a path.
  • DNS-rebinding protection explicitly disabled
    (mcp.dns_rebinding.protection_disabled), CVE-2025-66414 and CVE-2025-66416. Three of
    the 55 real servers do this. An informational sibling flags an HTTP transport with no
    transport-security settings in the file at all.
  • All-interface bind with no authentication in sight
    (mcp.network_exposure.bind_all_interfaces), CVE-2026-23744 and CVE-2025-49596.
  • One HTTP transport shared by every client (mcp.session.shared_http_transport),
    CVE-2026-25536.
  • JavaScript command injection through execSync on a template literal or a
    concatenation (web.cmd_injection.js_exec_interpolated; CVE-2025-53355, CVE-2025-53967,
    CVE-2026-0755) and spawn with shell: true (web.cmd_injection.spawn_shell_true).
  • A shell started through an argument list, ["bash", "-lc", command]
    (agent.excessive_agency.shell_argv).
  • Line jumping, cross-server tool shadowing and invisible Unicode in tool
    metadata (mcp.line_jumping.*, mcp.tool_shadowing.*, mcp.hidden_unicode.*), after
    Trail of Bits and Invariant Labs.
  • subprocess ... shell=True is now found behind nested parentheses, which a real server
    used (subprocess.run(subprocess.list2cmdline(cmd), ..., shell=True)), and
    asyncio.create_subprocess_shell is treated as the same sink. Both were misses on a
    third-party deliberately vulnerable file.
  • A rug-pull rule was written and removed. It matched await client.listTools(),
    which is how every correct client lists tools: 26 hits on the official SDK, all false.
    A rule that fires on all correct code is not a detection. docs/cve-coverage.md lists
    it and the other classes that deliberately have no rule.

Precision, measured on code nobody wrote for this tool

The first run of the previous rule set on the 55 servers reported 103 findings; adjudicated
by hand, 6 were plausible defects. Three idioms accounted for most of the rest, and each
is now excluded explicitly rather than by lowering a threshold:

  • f"... IN ({','.join('?' * n)})" and f"... FROM {TABLE_CONSTANT}" are not SQL
    injection; nor is '%' inside a quoted string (strftime('%s')) the formatting
    operator. Identifier interpolation into SET, SAVEPOINT, INSTALL and other
    statements that cannot take a bound parameter is now a separate informational rule.
  • MD5 or SHA-1 as a content fingerprint or cache key is informational; the reportable rule
    needs a password, credential or signature in the same expression.
  • A UTF-8 byte-order mark at the start of a .cs file is not a hidden instruction. The
    analyzer strips a leading BOM before matching, and the invisible-Unicode rule is anchored
    to the line so code that strips those characters is not reported for naming them.
  • A method definition called exec (protected exec(sql: string)) is not dynamic
    execution.

After the changes the same 55 servers (1,162,650 lines once the largest finished) report
30 findings, of which 22 were judged plausible defects on manual review: three
servers with DNS-rebinding protection switched off, two bound to every interface with no
authentication, a committed 64-hex API key in a docker-compose.yml, a shell-as-a-tool
server, a subprocess.run(list2cmdline(cmd), shell=True), and a short-link resolver that
follows redirects from any host that merely contains the expected domain. Plausible
means "worth a human's time", not "confirmed exploitable"; the eight remaining are HTTP
wrappers over a fixed base URL and a WeChat-mandated SHA-1.

On the 285,463-line benign corpus the raw count went from 21 to 38 (0.013 per 100
lines, under the 0.05 CI budget) and the reportable count from 1 to 3. All three are
recorded in benchmark/corpus.json: the official fetch server retrieving whatever URL the
model supplies, shell=True on an argument list in the SDK's CLI, and the SDK's own
backward-compatibility fallback that constructs TransportSecuritySettings with protection
off. Two further hits went away for the right reason: the fixed git server rejects
option-shaped targets three lines above the call the rule matches, so the rule now stands
down in any file that checks for a leading -.

Analyzer

  • Rules can carry two file-level gates: requires, a regex that must match somewhere
    in the file, and absent, one that must match nowhere. A single-site regex cannot say
    "this file defines MCP tools" or "this file never calls realpath"; a gate can.
    --self-check validates them.
  • A leading UTF-8 byte-order mark is stripped before matching.
  • A hang found by the pre-release stress pass, present since 0.1.0. The
    cross-tenant cache rule began with an unbounded \w* in front of a literal, which is
    quadratic; a 2 MB single-line file made one scan run for over ten minutes. The prefix
    is now bounded, and a test feeds every rule six hostile files (a 400 KB line, 2,000
    nested parentheses, 10,000 alternating quotes, a 10,000-placeholder f-string, 10,000
    repeated <IMPORTANT> tags, 50,000 zero-width characters) with a two-second ceiling
    each.

Benchmark

  • A second fixture corpus, demo/vulnerable-2026 and demo/safe-2026, with
    demo/ground-truth-2026.json naming the CVE each of its 16 instances mirrors.
    python benchmark/score_demo.py --corpus 2026 scores it (16 TP, 0 FP, 0 FN) and
    --corpus paper scores the frozen 22-instance set. CI runs both and requires the fixed
    halves to be silent.

Packaging

  • Dockerfile and a release workflow that publishes ghcr.io/dinakar-s/mcpvuln to
    GitHub Packages on every v* tag and attaches the sdist and wheel to the release.
  • CITATION.cff: the author's name is Dinakar, family name S.
  • 216 tests, up from 154.

v0.2.1 - packaging fix

Choose a tag to compare

@DINAKAR-S DINAKAR-S released this 05 Sep 09:40

Packaging only. No change to detection, scoring or reporting; 0.2.0 and 0.2.1 behave identically.

Fixes the PyPI page showing only a one-line summary instead of the README. python -m build builds the wheel from the sdist, and the README lived only at the repository root, so it was absent at wheel-build time and setup.py fell back to the short description without complaining. PyPI metadata is immutable per version, so fixing it required a release.

  • A PyPI-facing README now ships inside the package directory, with absolute links since relative ones do not resolve on PyPI. Wheel metadata goes from 2,330 to 9,967 characters.
  • setup.py rejects a stub shorter than 500 characters rather than degrading quietly again.
  • Project URLs expanded from two to eight: homepage, source, issues, changelog, benchmark, contributing, security policy, release notes.
  • Classifiers added for Python 3.13, console environment, testing topic and typing.
  • Corrects the rule count in the README, which still said twenty-seven after two cross-tenant rules were added. It is 29.
pip install --upgrade mcpvuln

v0.2.0 - 160x fewer false positives, reproducible scoring

Choose a tag to compare

@DINAKAR-S DINAKAR-S released this 05 Sep 09:42

Precision 1.000, Recall 0.864, F1 0.927 against a public ground truth, and 23 findings across 285,463 lines of clean code, down from 3,642.

Precision, recall and F1

demo/ground-truth.json documents 22 planted vulnerabilities across all four taxonomy layers. demo/safe is the same code with every one fixed, so it contributes only true negatives and precision is measured directly rather than sampled.

python benchmark/score_demo.py
TP FP FN Precision Recall F1
All layers 19 0 3 1.000 0.864 0.927
LLM 1 0 0 1.000 1.000 1.000
Traditional web 5 0 0 1.000 1.000 1.000
MCP 11 0 1 1.000 0.917 0.957
Agentic AI 2 0 2 1.000 0.500 0.667

The three misses are all properties of protocol state rather than of any line of code, which is why the Agentic AI layer scores lowest. These fixtures carry one clean instance of each class, so read this as evidence that the rules fire and discriminate, not as an estimate of recall on unseen code.

On 285,463 lines of clean, officially maintained MCP code, findings went from 3,642 to 23. A 160x reduction, and the one finding above the reporting threshold is a genuine bug in the official SDK.

Try it in thirty seconds

The repository ships the same MCP server twice, with every vulnerability in the first one fixed in the second.

pip install -e "mcp-scan[all]"
mcpvuln demo/vulnerable      # 20 findings, 12 categories, all four layers
mcpvuln demo/safe            # 0 findings

No API key. No network. The second command is the one that matters: any scanner finds planted bugs, but one that also fires on the corrected version is not measuring anything.

What changed

Detection

  • Fixed a regular expression responsible for 2,061 of 3,642 findings. wget .* | sh used an unescaped |, which in a regular expression is alternation rather than a shell pipe, so the alternative ' sh' matched the word "should".
  • Fixed four duplicate dictionary keys that silently discarded four pattern lists: 33 keys written, 29 surviving, 11 expressions never executed.
  • Removed twenty unanchored bare-substring patterns (latest, http://, open(, while True) that fired on comments, documentation and URLs.
  • Made case-insensitivity opt-in per pattern. Applied globally, DES\s*\( matched includes(.
  • Matching is whole-file rather than line-by-line, and comments, docstrings and prose are suppressed.
  • Every finding carries a confidence score used for ranking, filtering and the informational threshold.
  • Added suppression directives: # mcpvuln: ignore-file, # mcpvuln: ignore, # mcpvuln: ignore[rule.id].

Scoring is now computed, not generated

  • CVSS v4.0 base scores come from the cvss implementation of the FIRST specification. The same finding scores identically on every run; previously a language model was asked to write a score in prose.
  • SSVC is evaluated as the published decision tree. Exploitation is never reported as active, because a source scanner observes code, not exploitation in the wild.

Architecture

  • New scan contract: a versioned JSON document that is the only thing crossing a stage boundary. Save it with --json, diff it across commits, score it offline.
  • SecurityAnalysisTeam is now SecurityAnalysisPipeline. The previous class presented the stages as a coordinating multi-agent team, but the coordinating model was constructed and never invoked, and OPENAI_API_KEY appeared nowhere in the repository. The old name remains as a deprecated alias.
  • Detection requires no API key and no network. Only --narrative and --threat-intel reach out, and both degrade to a warning without their key.
  • Removed findings[:20], which silently discarded 99.4% of findings on a repository of any size.

Testing

  • 140 tests, from zero. Every defect above is held down by a regression test.
  • CI on Linux and Windows across Python 3.9, 3.11 and 3.12, plus a benchmark job that fails if the false-positive rate exceeds 0.05 per 100 LOC.
  • New benchmark with a pinned benign corpus, so precision is measurable rather than assumed.
  • Fixed an install broken since the first release: setup.py read README.md from the working directory rather than relative to itself, so the documented install failed on a fresh clone.

Benchmark

Version Findings on 285,463 clean lines Per 100 LOC
v0.1.0 3,642 1.28
v0.2.0 23 0.008

Corpus: modelcontextprotocol/python-sdk at d060b36 and modelcontextprotocol/servers at d73f99e. Reproduce with python benchmark/run_benchmark.py --fetch.

Upgrading from 0.1.0

SecurityAnalysisTeam still works but warns. Reports are written with --out rather than to the working directory. Full notes in CHANGELOG.md.

v0.1.0 - initial release (superseded)

Choose a tag to compare

@DINAKAR-S DINAKAR-S released this 05 Sep 08:56
497ba55

Initial release: regular-expression detection with language-model reporting.

Tagged so that the version evaluated in the accompanying paper stays reproducible. Not recommended for use. Measured at 1.28 findings per 100 lines of clean code, of which an adjudicated sample of twenty-five contained no true positives.

Use v0.2.0, which reduces that by 160x.