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.
- mcpvuln-0.2.0/MANIFEST.in +5 -0
- mcpvuln-0.2.0/PKG-INFO +321 -0
- mcpvuln-0.2.0/mcpvuln/__init__.py +37 -0
- mcpvuln-0.2.0/mcpvuln/cli.py +153 -0
- mcpvuln-0.2.0/mcpvuln/config.py +37 -0
- mcpvuln-0.2.0/mcpvuln/contract.py +102 -0
- mcpvuln-0.2.0/mcpvuln/firecrawl_integration.py +73 -0
- mcpvuln-0.2.0/mcpvuln/github_scraper.py +59 -0
- mcpvuln-0.2.0/mcpvuln/patterns.py +423 -0
- mcpvuln-0.2.0/mcpvuln/pipeline.py +116 -0
- mcpvuln-0.2.0/mcpvuln/report_generator.py +224 -0
- mcpvuln-0.2.0/mcpvuln/scoring.py +191 -0
- mcpvuln-0.2.0/mcpvuln/summary.py +163 -0
- mcpvuln-0.2.0/mcpvuln/team.py +22 -0
- mcpvuln-0.2.0/mcpvuln/vuln_analyzer.py +320 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/PKG-INFO +321 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/SOURCES.txt +34 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/dependency_links.txt +1 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/entry_points.txt +2 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/not-zip-safe +1 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/requires.txt +23 -0
- mcpvuln-0.2.0/mcpvuln.egg-info/top_level.txt +1 -0
- mcpvuln-0.2.0/pyproject.toml +15 -0
- mcpvuln-0.2.0/requirements.txt +14 -0
- mcpvuln-0.2.0/setup.cfg +4 -0
- mcpvuln-0.2.0/setup.py +74 -0
- mcpvuln-0.2.0/tests/test_analyzer.py +237 -0
- mcpvuln-0.2.0/tests/test_cli.py +42 -0
- mcpvuln-0.2.0/tests/test_contract.py +52 -0
- mcpvuln-0.2.0/tests/test_demo.py +104 -0
- mcpvuln-0.2.0/tests/test_demo_scoring.py +75 -0
- mcpvuln-0.2.0/tests/test_docstrings.py +84 -0
- mcpvuln-0.2.0/tests/test_paper_algorithms.py +328 -0
- mcpvuln-0.2.0/tests/test_patterns.py +61 -0
- mcpvuln-0.2.0/tests/test_report.py +70 -0
- mcpvuln-0.2.0/tests/test_scoring.py +101 -0
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
|
+
[](https://github.com/DINAKAR-S/Agentic-MCP-Scanner/actions/workflows/ci.yml)
|
|
59
|
+
[](mcp-scan/tests)
|
|
60
|
+
[](mcp-scan/benchmark)
|
|
61
|
+
[](LICENSE)
|
|
62
|
+
[](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
|