mcpvuln 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. mcpvuln-0.2.0/MANIFEST.in +5 -0
  2. mcpvuln-0.2.0/PKG-INFO +321 -0
  3. mcpvuln-0.2.0/mcpvuln/__init__.py +37 -0
  4. mcpvuln-0.2.0/mcpvuln/cli.py +153 -0
  5. mcpvuln-0.2.0/mcpvuln/config.py +37 -0
  6. mcpvuln-0.2.0/mcpvuln/contract.py +102 -0
  7. mcpvuln-0.2.0/mcpvuln/firecrawl_integration.py +73 -0
  8. mcpvuln-0.2.0/mcpvuln/github_scraper.py +59 -0
  9. mcpvuln-0.2.0/mcpvuln/patterns.py +423 -0
  10. mcpvuln-0.2.0/mcpvuln/pipeline.py +116 -0
  11. mcpvuln-0.2.0/mcpvuln/report_generator.py +224 -0
  12. mcpvuln-0.2.0/mcpvuln/scoring.py +191 -0
  13. mcpvuln-0.2.0/mcpvuln/summary.py +163 -0
  14. mcpvuln-0.2.0/mcpvuln/team.py +22 -0
  15. mcpvuln-0.2.0/mcpvuln/vuln_analyzer.py +320 -0
  16. mcpvuln-0.2.0/mcpvuln.egg-info/PKG-INFO +321 -0
  17. mcpvuln-0.2.0/mcpvuln.egg-info/SOURCES.txt +34 -0
  18. mcpvuln-0.2.0/mcpvuln.egg-info/dependency_links.txt +1 -0
  19. mcpvuln-0.2.0/mcpvuln.egg-info/entry_points.txt +2 -0
  20. mcpvuln-0.2.0/mcpvuln.egg-info/not-zip-safe +1 -0
  21. mcpvuln-0.2.0/mcpvuln.egg-info/requires.txt +23 -0
  22. mcpvuln-0.2.0/mcpvuln.egg-info/top_level.txt +1 -0
  23. mcpvuln-0.2.0/pyproject.toml +15 -0
  24. mcpvuln-0.2.0/requirements.txt +14 -0
  25. mcpvuln-0.2.0/setup.cfg +4 -0
  26. mcpvuln-0.2.0/setup.py +74 -0
  27. mcpvuln-0.2.0/tests/test_analyzer.py +237 -0
  28. mcpvuln-0.2.0/tests/test_cli.py +42 -0
  29. mcpvuln-0.2.0/tests/test_contract.py +52 -0
  30. mcpvuln-0.2.0/tests/test_demo.py +104 -0
  31. mcpvuln-0.2.0/tests/test_demo_scoring.py +75 -0
  32. mcpvuln-0.2.0/tests/test_docstrings.py +84 -0
  33. mcpvuln-0.2.0/tests/test_paper_algorithms.py +328 -0
  34. mcpvuln-0.2.0/tests/test_patterns.py +61 -0
  35. mcpvuln-0.2.0/tests/test_report.py +70 -0
  36. mcpvuln-0.2.0/tests/test_scoring.py +101 -0
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include requirements.txt
3
+ include .env.example
4
+ include LICENSE
5
+ recursive-include mcpvuln *.py
mcpvuln-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcpvuln
3
+ Version: 0.2.0
4
+ Summary: Vulnerability detection for Model Context Protocol codebases, with a reproducible benchmark
5
+ Home-page: https://github.com/DINAKAR-S/Agentic-MCP-Scanner/
6
+ Author: Dinakar S
7
+ Author-email: dinakars2003@gmail.com
8
+ Project-URL: Bug Reports, https://github.com/DINAKAR-S/Agentic-MCP-Scanner/issues
9
+ Project-URL: Source, https://github.com/DINAKAR-S/Agentic-MCP-Scanner/
10
+ Keywords: security,vulnerability,analysis,mcp,model-context-protocol,ai,llm,benchmark
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Information Technology
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: cvss==3.6
26
+ Provides-Extra: github
27
+ Requires-Dist: gitingest==0.1.4; extra == "github"
28
+ Provides-Extra: narrative
29
+ Requires-Dist: google-generativeai==0.8.5; extra == "narrative"
30
+ Requires-Dist: python-dotenv==1.1.1; extra == "narrative"
31
+ Provides-Extra: intel
32
+ Requires-Dist: firecrawl-py==2.16.3; extra == "intel"
33
+ Requires-Dist: python-dotenv==1.1.1; extra == "intel"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest==8.4.2; extra == "dev"
36
+ Requires-Dist: pytest-cov==6.2.1; extra == "dev"
37
+ Requires-Dist: ruff==0.13.0; extra == "dev"
38
+ Provides-Extra: all
39
+ Requires-Dist: firecrawl-py==2.16.3; extra == "all"
40
+ Requires-Dist: gitingest==0.1.4; extra == "all"
41
+ Requires-Dist: google-generativeai==0.8.5; extra == "all"
42
+ Requires-Dist: python-dotenv==1.1.1; extra == "all"
43
+ Dynamic: author
44
+ Dynamic: author-email
45
+ Dynamic: classifier
46
+ Dynamic: description
47
+ Dynamic: description-content-type
48
+ Dynamic: home-page
49
+ Dynamic: keywords
50
+ Dynamic: project-url
51
+ Dynamic: provides-extra
52
+ Dynamic: requires-dist
53
+ Dynamic: requires-python
54
+ Dynamic: summary
55
+
56
+ # mcpvuln
57
+
58
+ [![CI](https://github.com/DINAKAR-S/Agentic-MCP-Scanner/actions/workflows/ci.yml/badge.svg)](https://github.com/DINAKAR-S/Agentic-MCP-Scanner/actions/workflows/ci.yml)
59
+ [![Tests](https://img.shields.io/badge/tests-140%20passing-brightgreen)](mcp-scan/tests)
60
+ [![False positives](https://img.shields.io/badge/false%20positives-0.008%20per%20100%20LOC-brightgreen)](mcp-scan/benchmark)
61
+ [![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
62
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/)
63
+
64
+ **A vulnerability scanner for MCP that proves how often it is wrong.**
65
+
66
+ MCP lets an agent discover and call tools at runtime, from servers somebody else
67
+ operates. That moves the unit of trust from a single function call to a whole protocol
68
+ session, and the failures that follow have no equivalent in ordinary application
69
+ security: forged agent identities, trust scores computed but never enforced, audit logs
70
+ that can be rewritten, one tenant's context served to another.
71
+
72
+ There are scanners already. What none of them ships is a way to tell how much of the
73
+ output is real. They are evaluated only against code known to be broken, so they can
74
+ measure what they catch but never what they invent. That is the part nobody tests,
75
+ because it requires scanning code that has nothing wrong with it and counting what comes
76
+ back. This one ships that corpus, and every precision number below is produced by running
77
+ against it.
78
+
79
+ > Status: 140 tests, CI on Linux and Windows across Python 3.9 to 3.12, and a build that
80
+ > fails if the false-positive rate on clean code rises above 0.05 per 100 lines. It
81
+ > currently sits at 0.008.
82
+
83
+ ---
84
+
85
+ ## The situation
86
+
87
+ You inherit an MCP server. It exposes eleven tools to an agent that can spend money, read the customer database and deploy containers. You want to know what is wrong with it before it ships.
88
+
89
+ So you run a scanner. It returns 3,283 findings.
90
+
91
+ You open the first one. It flags this line:
92
+
93
+ ```
94
+ # Tests should be fast and deterministic
95
+ ```
96
+
97
+ as a **malicious server supply chain attack**. The next flags `import shutil`. The next flags a documentation URL because it contains the word `latest`. By finding forty you stop reading, and the two real problems, a JWT decoded with `verify_signature: False` and a container mounting `docker.sock`, are somewhere in the remaining three thousand.
98
+
99
+ That is not a hypothetical. It is what version 0.1.0 of **this tool** did, measured against the official MCP Python SDK. One typo caused most of it: `wget .* | sh` treats `|` as regex alternation rather than a shell pipe, so the pattern matched the string `" sh"`, so it matched the word **"should"**, 1,852 times.
100
+
101
+ | Situation | Without a control corpus | With one |
102
+ |---|---|---|
103
+ | Scanner returns 3,000 findings | Sounds thorough | You measure it on clean code and learn 3,000 is the noise floor |
104
+ | A pattern matches the word "should" | Ships, nobody notices | CI fails the false-positive budget |
105
+ | Someone asks "what is your precision?" | No answer is possible: you never scanned code without vulnerabilities | 0.008 findings per 100 LOC on 285k lines of clean code |
106
+ | A rule fires on a security control | Reported as a vulnerability | Regression test keeps it silent |
107
+ | CVSS score for the same finding | Differs between runs, a model wrote it | Computed from the FIRST spec, identical every run |
108
+
109
+ **Result of fixing that:** on 285,463 lines of clean, officially maintained MCP code, findings went from **3,642 to 23**, a 160x reduction, with **one** above the reporting confidence threshold, and that one is a genuine bug in the SDK.
110
+
111
+
112
+ ---
113
+
114
+ ## Try it in thirty seconds
115
+
116
+ The repository ships the **same MCP server twice**: `demo/vulnerable/` and `demo/safe/`.
117
+ Same files, same functions, same names. Every vulnerability in the first is fixed in the
118
+ second.
119
+
120
+ ```bash
121
+ git clone https://github.com/DINAKAR-S/Agentic-MCP-Scanner
122
+ cd Agentic-MCP-Scanner
123
+ pip install -e mcp-scan
124
+
125
+ mcpvuln demo/vulnerable # 20 findings, 12 categories, all four layers
126
+ mcpvuln demo/safe # 0 findings
127
+ ```
128
+
129
+ No API key. No network. Under a second.
130
+
131
+ The second command is the one that matters. Any scanner finds planted bugs; a scanner
132
+ that also fires on the fixed version is not measuring anything. See
133
+ [demo/README.md](demo/README.md) for what is planted and how each issue was fixed.
134
+
135
+ ---
136
+
137
+ ## Install
138
+
139
+ ```bash
140
+ pip install mcpvuln
141
+ ```
142
+
143
+ > **Not on PyPI yet.** Until it is, use the release wheel or a source checkout below.
144
+ > _(Delete this note once `twine upload` has run.)_
145
+
146
+ Core install pulls a single dependency and needs no credentials. Everything else is an
147
+ extra:
148
+
149
+ ```bash
150
+ pip install "mcpvuln[github]" # scan a GitHub URL directly
151
+ pip install "mcpvuln[narrative]" # model-written analyst commentary
152
+ pip install "mcpvuln[intel]" # external advisory lookup
153
+ pip install "mcpvuln[all]" # all of the above
154
+ ```
155
+
156
+ <details>
157
+ <summary>Other ways to install</summary>
158
+
159
+ **From a GitHub release**, without PyPI:
160
+
161
+ ```bash
162
+ pip install https://github.com/DINAKAR-S/Agentic-MCP-Scanner/releases/download/v0.2.0/mcpvuln-0.2.0-py3-none-any.whl
163
+ ```
164
+
165
+ **From source**, which is what you want if you intend to run the benchmark or the demo:
166
+
167
+ ```bash
168
+ git clone https://github.com/DINAKAR-S/Agentic-MCP-Scanner
169
+ cd Agentic-MCP-Scanner
170
+ pip install -e "mcp-scan[all]"
171
+ ```
172
+
173
+ **Verify the install:**
174
+
175
+ ```bash
176
+ mcpvuln --version
177
+ mcpvuln --self-check # validates the rule set: 29 rules, no duplicate ids
178
+ ```
179
+
180
+ </details>
181
+
182
+ ## Use
183
+
184
+ ```bash
185
+ mcpvuln ./my-mcp-server # offline. no API key needed.
186
+ mcpvuln https://github.com/org/repo # ingest from GitHub
187
+ mcpvuln ./repo --json scan.json # emit the scan contract
188
+ mcpvuln ./repo --min-confidence 0.7 # tighten the threshold
189
+ mcpvuln ./repo --fail-on high # exit non-zero, for CI
190
+ mcpvuln ./repo --narrative # add model commentary
191
+ mcpvuln --self-check # validate the pattern set
192
+ ```
193
+
194
+ **Detection needs no credentials.** No `GOOGLE_API_KEY`, no `OPENAI_API_KEY`, no network. Only `--narrative` and `--threat-intel` call out, and both degrade to a warning if their key is missing.
195
+
196
+ ## What it detects
197
+
198
+ Twenty-seven rules across four layers, each carrying a confidence prior and CVSS v4.0 base metrics.
199
+
200
+ | Layer | Covers |
201
+ |---|---|
202
+ | **MCP** | JWT verification disabled, weak HS256 secrets, agent-card auto-verification, audit-log mutation, privileged containers and `docker.sock` mounts, pipe-to-shell installs, tool-poisoning sinks, plaintext transport to non-loopback hosts |
203
+ | **Agentic AI** | Trust scores computed but never enforced as an authorisation floor, unscoped cross-agent memory queries, shell and REPL tools handed to an agent, unsigned goal mutation |
204
+ | **LLM** | Untrusted input concatenated into a system prompt, model context populated from a fetched or decoded remote source |
205
+ | **Traditional web** | Command injection, SQL injection, XSS, path traversal, hardcoded secrets, weak crypto, insecure RNG, unsafe deserialisation, dynamic execution |
206
+
207
+ Run `mcpvuln --self-check` to list them and validate the set.
208
+
209
+ ## How it works
210
+
211
+ ```
212
+ ingest -> detect -> score -> report
213
+ ```
214
+
215
+ - **detect** is deterministic regex over whole files, with comment, docstring and prose suppression, per-pattern case sensitivity, and a confidence score derived from the pattern's prior and the path it was found in. No model.
216
+ - **score** computes CVSS v4.0 base scores through the [`cvss`](https://pypi.org/project/cvss/) implementation of the FIRST specification, and evaluates the SSVC deployer decision tree. Both are reproducible. `Exploitation` is never reported as `active`, because a source scanner observes code, not exploitation in the wild.
217
+ - **report** renders Markdown with no model call. `--narrative` optionally adds written analysis on top, receiving the structured scan contract and processing **every** reportable finding in batches.
218
+
219
+ The boundary between stages is a versioned JSON document, the **scan contract**. Save it with `--json`, diff it across commits, score it offline, feed it to something else.
220
+
221
+ ## Precision, recall and F1
222
+
223
+ ```bash
224
+ python benchmark/score_demo.py
225
+ ```
226
+
227
+ `demo/ground-truth.json` documents all 22 planted vulnerabilities with their locations
228
+ and categories. `demo/safe` is the same code with every one of them fixed, so it
229
+ contributes only true negatives. Both halves and the ground truth are public, so these
230
+ numbers are reproducible by anyone:
231
+
232
+ | | TP | FP | FN | Precision | Recall | F1 |
233
+ |---|---|---|---|---|---|---|
234
+ | **All layers** | 19 | 0 | 3 | **1.000** | **0.864** | **0.927** |
235
+ | LLM | 1 | 0 | 0 | 1.000 | 1.000 | 1.000 |
236
+ | Traditional web | 5 | 0 | 0 | 1.000 | 1.000 | 1.000 |
237
+ | MCP | 11 | 0 | 1 | 1.000 | 0.917 | 0.957 |
238
+ | Agentic AI | 2 | 0 | 2 | 1.000 | 0.500 | 0.667 |
239
+
240
+ The three misses are worth naming, because they are not random. All three are properties
241
+ of **protocol state** rather than of any line of code: whether a plaintext endpoint is
242
+ reachable in production, whether a decaying trust score is enforced as an authorisation
243
+ floor, and whether a goal change carries an integrity check. A line-oriented matcher can
244
+ reach the file but cannot decide the question, which is why the Agentic AI layer scores
245
+ lowest and why runtime analysis is the roadmap.
246
+
247
+ **These fixtures carry one clean instance of each class, so detection here is easier than
248
+ on production code.** Read this as evidence that the rules fire and discriminate, not as
249
+ an estimate of recall on code the tool has not seen. The number that generalises is the
250
+ false-positive rate below, measured on 285,463 lines nobody wrote for this tool.
251
+
252
+ ## The benchmark
253
+
254
+ ```bash
255
+ python benchmark/run_benchmark.py --fetch
256
+ python benchmark/run_benchmark.py
257
+ ```
258
+
259
+ Clones two official MCP reference implementations at pinned commits, scans them, and reports findings per 100 LOC. They contain no known vulnerabilities of the classes under test, so **every finding is a candidate false positive**. CI fails if the rate exceeds 0.05 per 100 LOC.
260
+
261
+ | Corpus | Commit | Lines |
262
+ |---|---|---|
263
+ | `modelcontextprotocol/python-sdk` | `d060b36` | 260,948 |
264
+ | `modelcontextprotocol/servers` | `d73f99e` | 24,515 |
265
+
266
+ | Version | Findings | Per 100 LOC |
267
+ |---|---|---|
268
+ | v0.1.0 | 3,642 | 1.28 |
269
+ | v0.2.0 | 23 | 0.008 |
270
+
271
+ ## What is not built yet
272
+
273
+ Being explicit, because the gap between what a security tool claims and what it does is itself a security problem.
274
+
275
+ | Not built | What that means for you today |
276
+ |---|---|
277
+ | **Recall is not measured against a public vulnerable corpus** | The false-positive rate above is solid. The false-*negative* rate is not published, because the vulnerable corpus it was measured against is not yet released. Do not read a clean report as an absence of vulnerabilities. |
278
+ | **No runtime or protocol-state analysis** | Detection is static and line-oriented. Vulnerabilities defined by protocol state, whether a nonce is checked before accept, whether a trust score gates an action, are localisable but not reliably classifiable. This is the main known ceiling. |
279
+ | **No taint tracking** | A pattern sees one construct, not whether attacker-controlled data actually reaches it. Expect false positives on defensive code that mentions the same constructs. |
280
+ | **Category assignment is weaker than localisation** | The tool is better at finding the vulnerable file than at naming the vulnerability. Adjacent categories, the three identity-forgery variants especially, get confused. |
281
+ | **`--narrative` output is not reproducible** | It is a language model. The deterministic report underneath it is reproducible; the commentary is not. Do not cite narrative text as a measurement. |
282
+ | **Python is the only language with real context analysis** | Comment and string suppression uses `tokenize` for Python and a line-prefix heuristic elsewhere. JavaScript, Go and Rust get weaker suppression and therefore more noise. |
283
+ | **No autofix** | Findings carry remediation text. Nothing is changed for you. |
284
+
285
+ ## Development
286
+
287
+ ```bash
288
+ pip install -r mcp-scan/requirements-dev.txt
289
+ pytest mcp-scan/tests/ -q
290
+ ruff check mcp-scan/mcpvuln
291
+ ```
292
+
293
+ The test suite encodes the defects that shipped in v0.1.0 as regression tests: the word "should" must not be a supply-chain attack, `includes(` must not be weak cryptography, `allowed_origins=["http://127.0.0.1"]` must not be rogue server impersonation, and no finding may be silently truncated out of a report.
294
+
295
+ ## Contributing
296
+
297
+ Rules and patterns are the part most worth contributing to, and the bar is specific:
298
+ **a new rule must come with a true-positive test, a false-positive test, and a run of
299
+ the benign benchmark showing it costs nothing.** See [CONTRIBUTING.md](CONTRIBUTING.md).
300
+
301
+ Found a vulnerability class the scanner misses? Open an issue with a minimal code
302
+ sample. That is the most useful thing anyone can send.
303
+
304
+ ## Security
305
+
306
+ To report a vulnerability **in this tool**, see [SECURITY.md](SECURITY.md). Please do not
307
+ open a public issue for it.
308
+
309
+ ## Citing this work
310
+
311
+ This tool accompanies a paper under review. See [CITATION.cff](CITATION.cff), or use
312
+ GitHub's "Cite this repository" button.
313
+
314
+ ## Changelog
315
+
316
+ See [CHANGELOG.md](CHANGELOG.md). The short version: v0.2.0 reduced false positives on
317
+ clean code by 160x and made scoring reproducible.
318
+
319
+ ## License
320
+
321
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,37 @@
1
+ """mcpvuln: vulnerability detection for Model Context Protocol codebases.
2
+
3
+ The detection layer is deterministic and has no network or API-key dependency, so
4
+ ``VulnerabilityAnalyzer`` and the scoring and contract modules import cleanly on
5
+ their own. The reporting and threat-intelligence stages pull in heavier optional
6
+ dependencies and are imported lazily, on first use.
7
+ """
8
+
9
+ __version__ = "0.2.0"
10
+ __author__ = "Dinakar S"
11
+ __description__ = "MCP vulnerability detection with a reproducible benchmark"
12
+
13
+ __all__ = [
14
+ "VulnerabilityAnalyzer",
15
+ "Finding",
16
+ "PATTERNS",
17
+ "contract",
18
+ "scoring",
19
+ "SecurityAnalysisPipeline",
20
+ ]
21
+
22
+
23
+ def __getattr__(name):
24
+ # Lazy so that `import mcpvuln` never requires the reporting dependencies.
25
+ if name in ("VulnerabilityAnalyzer", "Finding"):
26
+ from . import vuln_analyzer
27
+ return getattr(vuln_analyzer, name)
28
+ if name == "PATTERNS":
29
+ from .patterns import PATTERNS
30
+ return PATTERNS
31
+ if name in ("contract", "scoring"):
32
+ import importlib
33
+ return importlib.import_module(f".{name}", __name__)
34
+ if name == "SecurityAnalysisPipeline":
35
+ from .pipeline import SecurityAnalysisPipeline
36
+ return SecurityAnalysisPipeline
37
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,153 @@
1
+ """Command line interface.
2
+
3
+ mcpvuln ./path/to/repo # offline scan, no API key needed
4
+ mcpvuln https://github.com/org/repo # ingest from GitHub
5
+ mcpvuln ./repo --narrative # add model-written analysis
6
+ mcpvuln ./repo --json out.json # emit the scan contract
7
+ mcpvuln ./repo --min-confidence 0.7 # tighten the detection threshold
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import logging
14
+ import os
15
+ import sys
16
+ from typing import List, Optional
17
+
18
+ from . import __version__
19
+ from . import contract as contract_mod
20
+ from .patterns import PATTERNS
21
+ from .patterns import validate as validate_patterns
22
+ from .pipeline import SecurityAnalysisPipeline
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ p = argparse.ArgumentParser(
27
+ prog="mcpvuln",
28
+ description="Vulnerability detection for Model Context Protocol codebases.",
29
+ epilog="Detection is deterministic and needs no API key. "
30
+ "--narrative and --threat-intel are the only network features.",
31
+ )
32
+ p.add_argument("target", nargs="*",
33
+ help="Local directory or GitHub repository URL. Repeatable.")
34
+ p.add_argument("--out", "-o", default=".",
35
+ help="Directory for generated reports (default: current directory).")
36
+ p.add_argument("--json", dest="json_path", default=None,
37
+ help="Also write the scan contract as JSON to this path. "
38
+ "With multiple targets it is used as a directory.")
39
+ p.add_argument("--min-confidence", type=float, default=None,
40
+ help="Drop findings below this detector confidence (0.0-1.0).")
41
+ p.add_argument("--narrative", action="store_true",
42
+ help="Add a model-written analyst narrative. Requires GOOGLE_API_KEY.")
43
+ p.add_argument("--threat-intel", action="store_true",
44
+ help="Fetch external advisories. Requires FIRECRAWL_API_KEY.")
45
+ p.add_argument("--model", default="models/gemini-2.5-pro",
46
+ help="Model for --narrative.")
47
+ p.add_argument("--fail-on", choices=["none", "low", "medium", "high", "critical"],
48
+ default="none",
49
+ help="Exit non-zero if any finding reaches this severity. For CI.")
50
+ p.add_argument("--quiet", "-q", action="store_true", help="Only print result paths.")
51
+ p.add_argument("--self-check", action="store_true",
52
+ help="Validate the pattern set and exit.")
53
+ p.add_argument("--version", action="version", version=f"mcpvuln {__version__}")
54
+ return p
55
+
56
+
57
+ _SEVERITY_RANK = {"none": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
58
+
59
+
60
+ def _worst_severity(contract: dict) -> str:
61
+ worst = "none"
62
+ for f in contract.get("findings", []):
63
+ if f.get("informational"):
64
+ continue
65
+ s = str(f.get("cvss_severity", "None")).lower()
66
+ if _SEVERITY_RANK.get(s, 0) > _SEVERITY_RANK[worst]:
67
+ worst = s
68
+ return worst
69
+
70
+
71
+ def _slug(label: str) -> str:
72
+ base = label.rstrip("/\\").replace("\\", "/").split("/")[-1]
73
+ return "".join(c if c.isalnum() or c in "-_." else "_" for c in base) or "scan"
74
+
75
+
76
+ def main(argv: Optional[List[str]] = None) -> int:
77
+ args = build_parser().parse_args(argv)
78
+
79
+ logging.basicConfig(
80
+ level=logging.WARNING if args.quiet else logging.INFO,
81
+ format="[%(levelname)s] %(message)s",
82
+ )
83
+
84
+ if args.self_check:
85
+ problems = validate_patterns()
86
+ if problems:
87
+ print(f"Pattern set INVALID, {len(problems)} problem(s):")
88
+ for p in problems:
89
+ print(f" - {p}")
90
+ return 1
91
+ print(f"Pattern set OK: {len(PATTERNS)} rules, all compile, no duplicate ids.")
92
+ return 0
93
+
94
+ if not args.target:
95
+ build_parser().print_help()
96
+ return 2
97
+
98
+ if args.narrative and not os.environ.get("GOOGLE_API_KEY"):
99
+ print("[warning] --narrative requested but GOOGLE_API_KEY is not set. "
100
+ "The deterministic report will still be written.", file=sys.stderr)
101
+ if args.threat_intel and not os.environ.get("FIRECRAWL_API_KEY"):
102
+ print("[warning] --threat-intel requested but FIRECRAWL_API_KEY is not set. "
103
+ "Continuing without it.", file=sys.stderr)
104
+
105
+ os.makedirs(args.out, exist_ok=True)
106
+ pipeline = SecurityAnalysisPipeline(
107
+ min_confidence=args.min_confidence,
108
+ narrative=args.narrative,
109
+ threat_intel=args.threat_intel,
110
+ model_name=args.model,
111
+ )
112
+
113
+ exit_code = 0
114
+ for target in args.target:
115
+ try:
116
+ contract = pipeline.run(target)
117
+ except Exception as exc:
118
+ print(f"[error] {target}: {exc}", file=sys.stderr)
119
+ exit_code = max(exit_code, 1)
120
+ continue
121
+
122
+ slug = _slug(contract["repository"])
123
+ md_path = os.path.join(args.out, f"{slug}_security_report.md")
124
+ with open(md_path, "w", encoding="utf-8") as fh:
125
+ fh.write(pipeline.report(contract))
126
+
127
+ if args.json_path:
128
+ jp = (os.path.join(args.json_path, f"{slug}.json")
129
+ if len(args.target) > 1 or os.path.isdir(args.json_path)
130
+ else args.json_path)
131
+ os.makedirs(os.path.dirname(os.path.abspath(jp)), exist_ok=True)
132
+ contract_mod.save(contract, jp)
133
+ print(f"contract: {jp}")
134
+
135
+ scan = contract["scan"]
136
+ print(f"report: {md_path}")
137
+ if not args.quiet:
138
+ print(f" {scan['findings_reportable']} reportable, "
139
+ f"{scan['findings_informational']} informational, "
140
+ f"across {scan['files_scanned']} files "
141
+ f"({scan['lines_scanned']:,} lines)")
142
+
143
+ if args.fail_on != "none":
144
+ worst = _worst_severity(contract)
145
+ if _SEVERITY_RANK[worst] >= _SEVERITY_RANK[args.fail_on]:
146
+ print(f"[fail-on] worst severity {worst} >= {args.fail_on}", file=sys.stderr)
147
+ exit_code = max(exit_code, 3)
148
+
149
+ return exit_code
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ """Configuration, including the threat-intelligence source list.
2
+
3
+ These URLs were previously a literal Python list inside ``cli.py``. Moving them
4
+ here means they can be overridden without editing code:
5
+
6
+ export MCPVULN_INTEL_SOURCES=/path/to/sources.txt # one URL per line
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import List
13
+
14
+ DEFAULT_INTEL_SOURCES: List[str] = [
15
+ "https://github.com/invariantlabs-ai/mcp-scan",
16
+ "https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks",
17
+ "https://invariantlabs.ai/blog/whatsapp-mcp-exploited",
18
+ "https://www.cyberark.com/resources/threat-research-blog/poison-everywhere-no-output-from-your-mcp-server-is-safe",
19
+ "https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/",
20
+ "https://hiddenlayer.com/innovation-hub/exploiting-mcp-tool-parameters/",
21
+ "https://www.backslash.security/blog/hundreds-of-mcp-servers-vulnerable-to-abuse",
22
+ "https://www.redhat.com/en/blog/model-context-protocol-mcp-understanding-security-risks-and-controls",
23
+ "https://strobes.co/blog/mcp-model-context-protocol-and-its-critical-vulnerabilities/",
24
+ "https://vulnerablemcp.info/index.html",
25
+ "https://unit42.paloaltonetworks.com/agentic-ai-threats/",
26
+ ]
27
+
28
+
29
+ def intel_sources() -> List[str]:
30
+ path = os.environ.get("MCPVULN_INTEL_SOURCES")
31
+ if path and os.path.isfile(path):
32
+ with open(path, encoding="utf-8") as fh:
33
+ urls = [line.strip() for line in fh
34
+ if line.strip() and not line.startswith("#")]
35
+ if urls:
36
+ return urls
37
+ return list(DEFAULT_INTEL_SOURCES)
@@ -0,0 +1,102 @@
1
+ """The scan contract: the versioned JSON document the pipeline hands between stages.
2
+
3
+ The detection stage produces one of these. The reporting stage consumes it. It is
4
+ the only thing that crosses the boundary, so a run can be replayed, diffed, or
5
+ scored offline without re-running detection, and the reporting model receives
6
+ structured input rather than an ad-hoc string.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import json
13
+ from collections.abc import Iterable
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from . import __version__
17
+ from .scoring import score_finding
18
+ from .summary import finding_summary, leadership_summary
19
+
20
+ SCHEMA_VERSION = "1.0"
21
+
22
+
23
+ def build(repository: str, findings: Iterable[dict], *,
24
+ files_scanned: int = 0, lines_scanned: int = 0,
25
+ external_intel: Optional[List[dict]] = None,
26
+ config: Optional[Dict[str, Any]] = None) -> dict:
27
+ """Assemble a scan contract from raw findings.
28
+
29
+ Findings are scored here, so the contract always carries deterministic CVSS
30
+ v4.0 vectors and SSVC priorities. Sorted most severe first.
31
+ """
32
+ scored = [score_finding(f) for f in findings]
33
+ for f in scored:
34
+ # Algorithm 2, step 6: business-facing summary, generated deterministically.
35
+ f["executive_summary"] = finding_summary(f)
36
+ scored.sort(key=lambda f: (-f["risk_index"], -f["confidence"], f["file"], f["line"]))
37
+
38
+ reportable = [f for f in scored if not f.get("informational")]
39
+ by_layer: Dict[str, int] = {}
40
+ by_category: Dict[str, int] = {}
41
+ by_severity: Dict[str, int] = {}
42
+ for f in scored:
43
+ by_layer[f["layer"]] = by_layer.get(f["layer"], 0) + 1
44
+ by_category[f["category"]] = by_category.get(f["category"], 0) + 1
45
+ by_severity[f["cvss_severity"]] = by_severity.get(f["cvss_severity"], 0) + 1
46
+
47
+ contract = {
48
+ "schema_version": SCHEMA_VERSION,
49
+ "tool": {"name": "mcpvuln", "version": __version__},
50
+ "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
51
+ "repository": repository,
52
+ "config": config or {},
53
+ "scan": {
54
+ "files_scanned": files_scanned,
55
+ "lines_scanned": lines_scanned,
56
+ "findings_total": len(scored),
57
+ "findings_reportable": len(reportable),
58
+ "findings_informational": len(scored) - len(reportable),
59
+ "by_layer": by_layer,
60
+ "by_category": by_category,
61
+ "by_severity": by_severity,
62
+ },
63
+ "findings": scored,
64
+ "external_intel": external_intel or [],
65
+ }
66
+ # Algorithm 2, step 13: top-line risk summary prepended for leadership.
67
+ contract["leadership_summary"] = leadership_summary(contract)
68
+ return contract
69
+
70
+
71
+ def dumps(contract: dict, indent: int = 2) -> str:
72
+ return json.dumps(contract, indent=indent, ensure_ascii=False)
73
+
74
+
75
+ def save(contract: dict, path: str) -> str:
76
+ with open(path, "w", encoding="utf-8") as fh:
77
+ fh.write(dumps(contract))
78
+ return path
79
+
80
+
81
+ def load(path: str) -> dict:
82
+ with open(path, encoding="utf-8") as fh:
83
+ return json.load(fh)
84
+
85
+
86
+ def validate(contract: dict) -> List[str]:
87
+ """Structural check. Empty list means valid."""
88
+ problems: List[str] = []
89
+ for key in ("schema_version", "tool", "repository", "scan", "findings"):
90
+ if key not in contract:
91
+ problems.append(f"missing top-level key: {key}")
92
+ if contract.get("schema_version") != SCHEMA_VERSION:
93
+ problems.append(
94
+ f"schema_version {contract.get('schema_version')!r} "
95
+ f"!= expected {SCHEMA_VERSION!r}"
96
+ )
97
+ for i, f in enumerate(contract.get("findings", [])):
98
+ for key in ("pattern_id", "category", "layer", "file", "line",
99
+ "confidence", "cvss_vector", "cvss_base_score", "ssvc"):
100
+ if key not in f:
101
+ problems.append(f"finding[{i}] missing key: {key}")
102
+ return problems