mcpprobe-scanner 0.3.1__tar.gz → 0.3.3__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.
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/CHANGELOG.md +59 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/CHECKS.md +1 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/PKG-INFO +4 -4
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/README.md +3 -3
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/github-action.md +10 -9
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/registry-report.md +4 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/report-schema.md +3 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/__init__.py +1 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/action.py +55 -12
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/auth.py +5 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/base.py +5 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/baseline.py +1 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/metadata.py +2 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/robustness.py +2 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/transport_conformance.py +4 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/cli.py +3 -3
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/models.py +108 -5
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/select.py +10 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/report/json_report.py +5 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/report/sarif.py +1 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/report/terminal.py +46 -10
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/scanner.py +6 -2
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_docs_and_action.py +36 -1
- mcpprobe_scanner-0.3.3/tests/test_finding_uids.py +172 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_registry_report.py +35 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_reports_and_cli.py +56 -2
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/.gitignore +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/LICENSE +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/NOTICE +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/SECURITY.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/benchmark.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/judges.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/marketplace-listing.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/docs/mcp-registry.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/authz.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/catalogue.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/chains.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/_util.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/active_stubs.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/capability.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/poisoning.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/proof.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/protocol_2026.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/checks/tool_surface.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/dnsutil.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/environment.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/governance.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/judges/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/judges/hf.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/policy.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/py.typed +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/readiness.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/redact.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/__main__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/aggregate.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/batch.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/cli.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/common.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/fetch.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/pipeline.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/render.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/static.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/registry/template.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/report/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/base.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/evidence.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/fingerprint.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/http.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/ratelimit.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/mcpprobe/transport/stdio.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/pyproject.toml +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/scripts/benchmark_judge.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/conftest.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/data/meta01_corpus.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/data/meta01_heldout.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/data/meta01_real_benign.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/data/registry/page-1.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/data/registry/page-2.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/data/sarif-schema-2.1.0.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/fake_server.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/fake_stdio_server.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_checks_03.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_checks_auth.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_checks_meta.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_checks_robustness.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_checks_transport.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_environment_and_evidence.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_fixture_outcomes.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_gate_and_policy.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_judges.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_meta01_corpus.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.3}/tests/test_transport.py +0 -0
|
@@ -6,6 +6,65 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.3] - 2026-11-03
|
|
10
|
+
|
|
11
|
+
Findings in one report no longer share an identity. Nothing about what the checks
|
|
12
|
+
detect has changed.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- A report could contain the same finding identity more than once. A finding was
|
|
17
|
+
identified by its check ID and location only, but several checks report more than one
|
|
18
|
+
thing at the same location (TRANS-03 weak and forged session IDs, TRANS-05 plain HTTP
|
|
19
|
+
and missing security headers, ERR-01 verbose errors and the `Server` header, META-03
|
|
20
|
+
arbitrary and high-impact capabilities; also AUTH-03, AUTH-04, DOS-01, META-02, META-04
|
|
21
|
+
and TRANS-04), so their SARIF results shared a `partialFingerprints` value and
|
|
22
|
+
code-scanning tools could merge them. A server that
|
|
23
|
+
listed the same tool twice also made META-01 and META-05 report the same finding twice,
|
|
24
|
+
which counted it twice in `counts`.
|
|
25
|
+
- Each finding now has a `uid` (`<check id>:<location>`, plus `:<variant>` where a check
|
|
26
|
+
reports more than one thing at one location) that is unique within the report and is
|
|
27
|
+
used as the SARIF fingerprint. Findings reported twice with identical content are
|
|
28
|
+
merged into one, keeping the evidence of both, so `counts`, `max_severity`,
|
|
29
|
+
`findings_max_severity`, attack chains and SARIF results all come from the same
|
|
30
|
+
de-duplicated list. `id` and SARIF `ruleId` are unchanged (still the check ID).
|
|
31
|
+
- AUTH-03 assesses an authorization server listed twice in the protected resource
|
|
32
|
+
metadata once.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- JSON report: findings have new `uid` and `variant` fields (schema stays 0.3; fields
|
|
37
|
+
were only added). A finding without a variant keeps the SARIF fingerprint it had in
|
|
38
|
+
0.3.2; findings that now have a variant get a new one, so code scanning may close the
|
|
39
|
+
old alert and open a new one for them once.
|
|
40
|
+
|
|
41
|
+
## [0.3.2] - 2026-11-02
|
|
42
|
+
|
|
43
|
+
Two counting fixes. Nothing about what the checks detect has changed.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Attack chains were left out of the summary counts and the pass/fail decision. A scan
|
|
48
|
+
could list a CRITICAL `CHAIN-01` while the summary line said "critical 0", and
|
|
49
|
+
`--fail-on critical` passed (exit 0) because no single finding was critical. The
|
|
50
|
+
terminal now prints a second count line for attack chains under the findings line, for
|
|
51
|
+
example `Attack chains critical 1 high 1 ... (2 chains, built from the findings counted
|
|
52
|
+
above)`. Chains are counted on their own line, not added to the findings counts, because
|
|
53
|
+
each chain is made of findings that are already counted. The exit code and the
|
|
54
|
+
`Result:` line now take chains into account, and the `Result:` line says whether
|
|
55
|
+
findings, attack chains or both reached the threshold.
|
|
56
|
+
- The JSON summary has a new `chain_counts` field and a `findings_max_severity` field;
|
|
57
|
+
`max_severity` is now the worst of findings and attack chains (the value the exit code
|
|
58
|
+
uses). SARIF run properties have a new `chainCounts`. `counts` is unchanged: findings
|
|
59
|
+
only, one per SARIF result.
|
|
60
|
+
- The GitHub Action's annotation and job summary mention attack chains and show them in
|
|
61
|
+
their own column.
|
|
62
|
+
- Registry survey funnel: a remote URL listed more than once (by a second entry, or as
|
|
63
|
+
both streamable-http and SSE) was dropped without being counted, so `remote_urls` minus
|
|
64
|
+
the exclusions did not equal `unique_hosts` (36,687 - 9,976 = 26,711, not 26,617). These
|
|
65
|
+
are now counted as `duplicate_url`, the summary has `selected_urls`, and the funnel adds
|
|
66
|
+
up exactly. Re-run `reaggregate` to correct an existing `aggregates.json`.
|
|
67
|
+
|
|
9
68
|
## [0.3.1] - 2026-11-01
|
|
10
69
|
|
|
11
70
|
Fewer false tool-poisoning alarms. META-01 (tool poisoning) was flagging ordinary
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mcpprobe checks catalogue
|
|
2
2
|
|
|
3
|
-
Generated from the check metadata of mcpprobe 0.3.
|
|
3
|
+
Generated from the check metadata of mcpprobe 0.3.3 (`mcpprobe checks --format markdown`). Do not edit by hand.
|
|
4
4
|
|
|
5
5
|
Standards: OWASP MCP Top 10 (IDs `MCP0x:2025`, still a beta list, so shown with `(beta)`), OWASP Top 10 for Agentic Applications (`ASI0x`), OWASP Top 10 for LLM Applications 2026 (`LLM0x:2026`, primary) and 2025 (`LLM0x:2025`, kept for one release cycle). Severities are defaults; a check can raise or lower the severity of an individual finding (for example loopback-only servers are reported one level lower), and heuristic checks never rate a finding Critical on their own.
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcpprobe-scanner
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Security scanner for Model Context Protocol (MCP) servers: auth, transport, tool-metadata and poisoning checks with JSON and SARIF output.
|
|
5
5
|
Project-URL: Homepage, https://mcpprobe.tech
|
|
6
6
|
Project-URL: Scanning policy, https://mcpprobe.tech/scanning-policy
|
|
@@ -182,8 +182,8 @@ process are in [docs/registry-report.md](docs/registry-report.md).
|
|
|
182
182
|
|
|
183
183
|
| Code | Meaning |
|
|
184
184
|
|---|---|
|
|
185
|
-
| `0` | No finding at or above `--fail-on` (default `high`) |
|
|
186
|
-
| `1` | At least one finding at or above `--fail-on` |
|
|
185
|
+
| `0` | No finding or attack chain at or above `--fail-on` (default `high`) |
|
|
186
|
+
| `1` | At least one finding or attack chain at or above `--fail-on` |
|
|
187
187
|
| `2` | Error: target unreachable, bad arguments, ownership verification failed, authorisation refused, or the target opted out |
|
|
188
188
|
| `3` | The target answered but is not an MCP server (for example an ordinary web site); every check is reported not applicable and nothing passes |
|
|
189
189
|
|
|
@@ -202,7 +202,7 @@ jobs:
|
|
|
202
202
|
runs-on: ubuntu-latest
|
|
203
203
|
steps:
|
|
204
204
|
- uses: actions/checkout@v4
|
|
205
|
-
- uses: mcpprobe-dev/mcpprobe@v0.3.
|
|
205
|
+
- uses: mcpprobe-dev/mcpprobe@v0.3.3
|
|
206
206
|
id: scan
|
|
207
207
|
with:
|
|
208
208
|
target: https://staging.your-server.example/mcp
|
|
@@ -139,8 +139,8 @@ process are in [docs/registry-report.md](docs/registry-report.md).
|
|
|
139
139
|
|
|
140
140
|
| Code | Meaning |
|
|
141
141
|
|---|---|
|
|
142
|
-
| `0` | No finding at or above `--fail-on` (default `high`) |
|
|
143
|
-
| `1` | At least one finding at or above `--fail-on` |
|
|
142
|
+
| `0` | No finding or attack chain at or above `--fail-on` (default `high`) |
|
|
143
|
+
| `1` | At least one finding or attack chain at or above `--fail-on` |
|
|
144
144
|
| `2` | Error: target unreachable, bad arguments, ownership verification failed, authorisation refused, or the target opted out |
|
|
145
145
|
| `3` | The target answered but is not an MCP server (for example an ordinary web site); every check is reported not applicable and nothing passes |
|
|
146
146
|
|
|
@@ -159,7 +159,7 @@ jobs:
|
|
|
159
159
|
runs-on: ubuntu-latest
|
|
160
160
|
steps:
|
|
161
161
|
- uses: actions/checkout@v4
|
|
162
|
-
- uses: mcpprobe-dev/mcpprobe@v0.3.
|
|
162
|
+
- uses: mcpprobe-dev/mcpprobe@v0.3.3
|
|
163
163
|
id: scan
|
|
164
164
|
with:
|
|
165
165
|
target: https://staging.your-server.example/mcp
|
|
@@ -32,7 +32,7 @@ jobs:
|
|
|
32
32
|
|
|
33
33
|
- name: Scan the MCP server
|
|
34
34
|
id: scan
|
|
35
|
-
uses: mcpprobe-dev/mcpprobe@v0.3.
|
|
35
|
+
uses: mcpprobe-dev/mcpprobe@v0.3.3
|
|
36
36
|
with:
|
|
37
37
|
target: https://staging.your-server.example/mcp
|
|
38
38
|
fail-on: high
|
|
@@ -61,7 +61,7 @@ A manually triggered version with a URL input lives in
|
|
|
61
61
|
| Input | Required | Default | Meaning |
|
|
62
62
|
|---|---|---|---|
|
|
63
63
|
| `target` | yes | | MCP endpoint URL (Streamable HTTP), for example `https://staging.example.com/mcp`. |
|
|
64
|
-
| `fail-on` | no | `high` | Fail the step when any finding is at or above this severity: `info`, `low`, `medium`, `high`, `critical`. |
|
|
64
|
+
| `fail-on` | no | `high` | Fail the step when any finding or attack chain is at or above this severity: `info`, `low`, `medium`, `high`, `critical`. |
|
|
65
65
|
| `rate` | no | `5` | Maximum requests per second, 0.1 to 10 (10 is a hard ceiling). |
|
|
66
66
|
| `sarif-output` | no | `mcpprobe.sarif` | Where to write SARIF 2.1.0, relative to the workspace. |
|
|
67
67
|
| `sarif-location` | no | target URL | Repository file to anchor results to. Without it, results point at the endpoint URL, which code scanning shows without a file link. |
|
|
@@ -82,8 +82,8 @@ A manually triggered version with a URL input lives in
|
|
|
82
82
|
|
|
83
83
|
| Code | `result` | Meaning | What to do |
|
|
84
84
|
|---|---|---|---|
|
|
85
|
-
| `0` | `clean` | No finding at or above `fail-on`. Lower findings are still in the SARIF. | Upload SARIF. |
|
|
86
|
-
| `1` | `findings` | At least one finding at or above `fail-on`. | Upload SARIF; fix or triage the alerts. |
|
|
85
|
+
| `0` | `clean` | No finding or attack chain at or above `fail-on`. Lower findings are still in the SARIF. | Upload SARIF. |
|
|
86
|
+
| `1` | `findings` | At least one finding or attack chain at or above `fail-on`. | Upload SARIF; fix or triage the alerts. |
|
|
87
87
|
| `2` | `error` | Target unreachable, bad input, the target opted out, or authorisation refused. | Read the step log. Do not upload. |
|
|
88
88
|
| `3` | `not-mcp` | The URL answered, but not as an MCP server (for example an HTML page or the wrong path). Nothing was checked and nothing passed. | Fix the URL or the deployment. Do not upload. |
|
|
89
89
|
|
|
@@ -100,8 +100,9 @@ known false positives, in text and Markdown), a `helpUri`, `security-severity`
|
|
|
100
100
|
(critical 9.5, high 7.5, medium 5.0, low 2.5, info 0.5), and tags: `security`, `mcp`,
|
|
101
101
|
the mode, and the OWASP MCP Top 10, Agentic (ASI) and LLM Top 10 IDs. Levels are
|
|
102
102
|
`error` for critical and high, `warning` for medium, `note` for low and info. Each
|
|
103
|
-
result carries a stable `partialFingerprints` key (check ID +
|
|
104
|
-
|
|
103
|
+
result carries a stable `partialFingerprints` key (the finding's `uid`: check ID +
|
|
104
|
+
location, plus a variant where one check reports several things at one location), so
|
|
105
|
+
alerts stay the same across runs and no two results in one run share it. Checks that were not applicable or skipped appear as tool
|
|
105
106
|
notifications on the analysis, never as passes.
|
|
106
107
|
|
|
107
108
|
## Limits and gotchas
|
|
@@ -109,7 +110,7 @@ notifications on the analysis, never as passes.
|
|
|
109
110
|
- **Reachability.** The container runs on the runner's Docker network, so
|
|
110
111
|
`127.0.0.1` inside it is not the runner. To scan a server started earlier in the
|
|
111
112
|
same job, use the CLI instead of the Action:
|
|
112
|
-
`pipx run --spec mcpprobe-scanner==0.3.
|
|
113
|
+
`pipx run --spec mcpprobe-scanner==0.3.3 mcpprobe http://127.0.0.1:8000/mcp --sarif mcpprobe.sarif`.
|
|
113
114
|
For a remote target, it must be reachable from GitHub-hosted runners.
|
|
114
115
|
- **Private repositories** need GitHub Code Security (Advanced Security) for code
|
|
115
116
|
scanning uploads. Without it, keep the scan step as the gate and save
|
|
@@ -121,6 +122,6 @@ notifications on the analysis, never as passes.
|
|
|
121
122
|
plans to deprecate v3; `upload-sarif@v4` takes the same inputs. Switch when your
|
|
122
123
|
organisation does.
|
|
123
124
|
- **Active mode** (`extra-args: --active ...`) also needs `--authorised-by`,
|
|
124
|
-
`--scope` and `--verify dns-txt`; see the README. In 0.3.
|
|
125
|
+
`--scope` and `--verify dns-txt`; see the README. In 0.3.3 the active checks are
|
|
125
126
|
stubs.
|
|
126
|
-
- **Pin a version.** Use `@v0.3.
|
|
127
|
+
- **Pin a version.** Use `@v0.3.3` (or a full commit SHA), not `@main`.
|
|
@@ -47,6 +47,10 @@ These rules are in code, not in this document only. The tests in
|
|
|
47
47
|
loopback, private, CGNAT, link-local or reserved address. The connection is pinned to
|
|
48
48
|
the vetted address; TLS still verifies the hostname through SNI. IP-literal URLs,
|
|
49
49
|
templated URLs (`{tenant}`) and local names are dropped before scanning.
|
|
50
|
+
- **Funnel arithmetic.** Every listed remote URL is either selected or counted under exactly
|
|
51
|
+
one reason in `funnel.excluded` (`duplicate_host`: another URL on a host already selected;
|
|
52
|
+
`duplicate_url` *(0.3.2)*: the same URL listed again by another entry or transport), so
|
|
53
|
+
`remote_urls - sum(excluded) == selected_urls == unique_hosts` with one URL per host.
|
|
50
54
|
- **Same origin only.** A request to any host other than the target is refused.
|
|
51
55
|
- **Stop on refusal.** The first HTTP 429 or 403 stops all traffic to that rate key for
|
|
52
56
|
the rest of the run and for resumed runs. `Retry-After` is not waited out.
|
|
@@ -24,7 +24,7 @@ or removed.
|
|
|
24
24
|
| `readiness` *(0.3)* | array | Directory listing readiness, see below. Always present (for `not_mcp`/`unreachable` every item is `not_assessable`). |
|
|
25
25
|
| `attestations` | array | Governance items a scan cannot assess. |
|
|
26
26
|
| `notes` | array of string | |
|
|
27
|
-
| `summary` | object | `counts` per severity, `max_severity
|
|
27
|
+
| `summary` | object | `counts` per severity (findings only), `chain_counts` *(0.3.2, attack chains per severity; each chain is built from findings already in `counts`)*, `max_severity` (from 0.3.2 the worst of findings and chains, the value the exit code uses), `findings_max_severity` *(0.3.2)*, `target_kind`, `checks_passed`, `checks_with_findings`, `checks_not_applicable`, `checks_applicable`, `checks_ran`, `checks_skipped`, `checks_not_implemented`, `checks_error`. |
|
|
28
28
|
| `evidence` | array | Every request/response pair. |
|
|
29
29
|
|
|
30
30
|
## `checks[]`
|
|
@@ -52,6 +52,8 @@ A check whose requests were rate limited beyond the budget is reported
|
|
|
52
52
|
| Field | Meaning |
|
|
53
53
|
|---|---|
|
|
54
54
|
| `id`, `title`, `severity`, `confidence`, `standards`, `description`, `fix`, `location`, `details`, `false_positive_notes` | As in 0.1. |
|
|
55
|
+
| `uid` *(0.3.3)* | Identity of the finding, unique within the report and stable across scans: `<id>:<location or ->`, plus `:<variant>` when set. `id` is the check ID, which several findings can share; `uid` tells them apart. It is also the SARIF `partialFingerprints["mcpprobe/v1"]` value. |
|
|
56
|
+
| `variant` *(0.3.3)* | `null` for a check's main finding; a short name (e.g. `forged-session-id`, `plain-http`, `server-header`) for another finding the same check reports at the same location. When two findings would still share a `uid` but say different things (for example two tools listed under the same name with different descriptions), each gets an 8-character variant derived from its content. A finding reported twice with identical content (for example because the server listed the same tool twice) appears once, with the evidence of both. |
|
|
55
57
|
| `confidence_reason` *(0.2)* | One sentence; always present when `confidence` is not `high`. |
|
|
56
58
|
| `verify` *(0.2)* | One runnable line that confirms the fix (a `curl` for HTTP targets, using the real target URL; `$TOKEN` where credentials are needed). |
|
|
57
59
|
| `affected` *(0.2)* | Tools, headers or paths affected. A check emits one finding per root cause, listing every affected item. |
|
|
@@ -6,8 +6,8 @@ always writes SARIF, records outputs in ``$GITHUB_OUTPUT``, writes a job
|
|
|
6
6
|
summary to ``$GITHUB_STEP_SUMMARY``, emits one workflow annotation that names
|
|
7
7
|
the outcome, and exits with the scanner's exit code:
|
|
8
8
|
|
|
9
|
-
- 0 clean (no finding at or above ``fail-on``)
|
|
10
|
-
- 1
|
|
9
|
+
- 0 clean (no finding or attack chain at or above ``fail-on``)
|
|
10
|
+
- 1 a finding or attack chain at or above ``fail-on``
|
|
11
11
|
- 2 error (unreachable target, bad input, opt-out, authorisation refused)
|
|
12
12
|
- 3 the target answered but is not an MCP server (wrong URL or path)
|
|
13
13
|
|
|
@@ -89,19 +89,49 @@ def sarif_counts(path: Path) -> dict[str, int]:
|
|
|
89
89
|
return counts
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
def
|
|
92
|
+
def sarif_chain_counts(path: Path) -> dict[str, int]:
|
|
93
|
+
"""Count attack chains (``runs[].properties.chains``) per severity; empty when unreadable.
|
|
94
|
+
|
|
95
|
+
Chains are not SARIF results: each one is built from findings that are already
|
|
96
|
+
results, so they are counted on their own.
|
|
97
|
+
"""
|
|
98
|
+
counts = dict.fromkeys(_SEVERITIES, 0)
|
|
99
|
+
try:
|
|
100
|
+
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
for run in data.get("runs", []):
|
|
102
|
+
for chain in run.get("properties", {}).get("chains", []):
|
|
103
|
+
sev = str(chain.get("severity", "info"))
|
|
104
|
+
counts[sev if sev in counts else "info"] += 1
|
|
105
|
+
except (OSError, ValueError, AttributeError):
|
|
106
|
+
return {}
|
|
107
|
+
return counts
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def describe(
|
|
111
|
+
code: int,
|
|
112
|
+
target: str,
|
|
113
|
+
fail_on: str,
|
|
114
|
+
counts: Mapping[str, int],
|
|
115
|
+
chain_counts: Mapping[str, int] | None = None,
|
|
116
|
+
) -> tuple[str, str, str]:
|
|
93
117
|
"""Return (annotation level, title, message) for an exit code."""
|
|
94
118
|
total = sum(counts.values())
|
|
95
119
|
breakdown = ", ".join(f"{counts[s]} {s}" for s in _SEVERITIES if counts.get(s))
|
|
120
|
+
chain_counts = chain_counts or {}
|
|
121
|
+
chains = sum(chain_counts.values())
|
|
122
|
+
chain_breakdown = ", ".join(f"{chain_counts[s]} {s}" for s in _SEVERITIES if chain_counts.get(s))
|
|
123
|
+
chain_text = f" and {chains} attack chain(s) built from them ({chain_breakdown})" if chains else ""
|
|
96
124
|
if code == EXIT_CLEAN:
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
below = ", ".join(x for x in (breakdown, f"attack chains: {chain_breakdown}" if chains else "") if x)
|
|
126
|
+
found = f" ({below} below the threshold)" if below else ""
|
|
127
|
+
what = "finding or attack chain" if chains else "finding"
|
|
128
|
+
return "notice", "mcpprobe: passed", f"No {what} at or above '{fail_on}' on {target}{found}."
|
|
99
129
|
if code == EXIT_FINDINGS:
|
|
100
130
|
return (
|
|
101
131
|
"error",
|
|
102
132
|
"mcpprobe: findings",
|
|
103
|
-
f"{total} finding(s) on {target} ({breakdown}); at least one is at or above
|
|
104
|
-
"See the job summary or the code scanning alerts.",
|
|
133
|
+
f"{total} finding(s) on {target} ({breakdown}){chain_text}; at least one is at or above "
|
|
134
|
+
f"'{fail_on}'. See the job summary or the code scanning alerts.",
|
|
105
135
|
)
|
|
106
136
|
if code == EXIT_NOT_MCP:
|
|
107
137
|
return (
|
|
@@ -119,7 +149,14 @@ def describe(code: int, target: str, fail_on: str, counts: Mapping[str, int]) ->
|
|
|
119
149
|
)
|
|
120
150
|
|
|
121
151
|
|
|
122
|
-
def _summary(
|
|
152
|
+
def _summary(
|
|
153
|
+
code: int,
|
|
154
|
+
target: str,
|
|
155
|
+
fail_on: str,
|
|
156
|
+
counts: Mapping[str, int],
|
|
157
|
+
message: str,
|
|
158
|
+
chain_counts: Mapping[str, int] | None = None,
|
|
159
|
+
) -> str:
|
|
123
160
|
lines = [
|
|
124
161
|
"## mcpprobe MCP security scan",
|
|
125
162
|
"",
|
|
@@ -130,8 +167,13 @@ def _summary(code: int, target: str, fail_on: str, counts: Mapping[str, int], me
|
|
|
130
167
|
"",
|
|
131
168
|
]
|
|
132
169
|
if code in (EXIT_CLEAN, EXIT_FINDINGS) and counts:
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
if chain_counts and sum(chain_counts.values()):
|
|
171
|
+
lines += ["| Severity | Findings | Attack chains |", "|---|---|---|"]
|
|
172
|
+
lines += [f"| {s} | {counts[s]} | {chain_counts.get(s, 0)} |" for s in _SEVERITIES]
|
|
173
|
+
lines += ["", "Each attack chain is built from findings counted in the Findings column."]
|
|
174
|
+
else:
|
|
175
|
+
lines += ["| Severity | Findings |", "|---|---|"]
|
|
176
|
+
lines += [f"| {s} | {counts[s]} |" for s in _SEVERITIES]
|
|
135
177
|
lines.append("")
|
|
136
178
|
lines.append(
|
|
137
179
|
"Docs: https://mcpprobe.tech · checks: https://github.com/mcpprobe-dev/mcpprobe/blob/main/CHECKS.md"
|
|
@@ -157,7 +199,8 @@ def run(env: Mapping[str, str] | None = None) -> int:
|
|
|
157
199
|
fail_on = argv[argv.index("--fail-on") + 1]
|
|
158
200
|
sarif = argv[argv.index("--sarif") + 1]
|
|
159
201
|
counts = sarif_counts(Path(sarif))
|
|
160
|
-
|
|
202
|
+
chain_counts = sarif_chain_counts(Path(sarif))
|
|
203
|
+
level, title, message = describe(code, target, fail_on, counts, chain_counts)
|
|
161
204
|
print(f"::{level} title={_escape_property(title)}::{_escape(message)}", file=sys.stderr)
|
|
162
205
|
out = env.get("GITHUB_OUTPUT")
|
|
163
206
|
if out:
|
|
@@ -168,7 +211,7 @@ def run(env: Mapping[str, str] | None = None) -> int:
|
|
|
168
211
|
summary = env.get("GITHUB_STEP_SUMMARY")
|
|
169
212
|
if summary:
|
|
170
213
|
with Path(summary).open("a", encoding="utf-8") as fh:
|
|
171
|
-
fh.write(_summary(code, target, fail_on, counts, message))
|
|
214
|
+
fh.write(_summary(code, target, fail_on, counts, message, chain_counts))
|
|
172
215
|
return code
|
|
173
216
|
|
|
174
217
|
|
|
@@ -401,7 +401,7 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
401
401
|
"metadata is missing (AUTH-02).",
|
|
402
402
|
related_to="AUTH-02",
|
|
403
403
|
)
|
|
404
|
-
servers =
|
|
404
|
+
servers = list(dict.fromkeys(s for s in prm.get("authorization_servers") or [] if isinstance(s, str)))
|
|
405
405
|
if not servers:
|
|
406
406
|
raise NotApplicable(
|
|
407
407
|
"The protected resource metadata lists no authorization server (AUTH-02).",
|
|
@@ -501,6 +501,7 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
501
501
|
confidence=Confidence.HIGH,
|
|
502
502
|
evidence=evidence_of(*responses),
|
|
503
503
|
location=issuer,
|
|
504
|
+
variant="plain-http-endpoints",
|
|
504
505
|
details={"insecure_endpoints": insecure},
|
|
505
506
|
affected=insecure,
|
|
506
507
|
proof=doc_proof,
|
|
@@ -511,6 +512,7 @@ class Auth03AuthorizationServerMetadata(Check):
|
|
|
511
512
|
findings.append(
|
|
512
513
|
self.finding(
|
|
513
514
|
title="Authorization server issuer mismatch",
|
|
515
|
+
variant="issuer-mismatch",
|
|
514
516
|
description=f"Metadata issuer {doc['issuer']!r} differs from advertised {issuer!r}.",
|
|
515
517
|
severity=Severity.LOW,
|
|
516
518
|
confidence=Confidence.MEDIUM,
|
|
@@ -625,6 +627,8 @@ class Auth04TokenAudienceAndPassthrough(Check):
|
|
|
625
627
|
else "The response was 2xx but carried no JSON-RPC result, so acceptance is inferred from the status only.",
|
|
626
628
|
evidence=evidence_of(resp),
|
|
627
629
|
location=ctx.target,
|
|
630
|
+
# Both self-minted tokens can be accepted; the second needs its own uid.
|
|
631
|
+
variant=None if label == "HS256/random-key" else "alg-none",
|
|
628
632
|
details={"probe": label, "claims": now_claims},
|
|
629
633
|
affected=[label],
|
|
630
634
|
proof=proof.exchange(
|
|
@@ -186,6 +186,7 @@ class Check(abc.ABC):
|
|
|
186
186
|
confidence: Confidence = Confidence.HIGH,
|
|
187
187
|
evidence: list[EvidenceRecord] | None = None,
|
|
188
188
|
location: str | None = None,
|
|
189
|
+
variant: str | None = None,
|
|
189
190
|
fix: str | None = None,
|
|
190
191
|
details: dict[str, Any] | None = None,
|
|
191
192
|
title: str | None = None,
|
|
@@ -198,7 +199,9 @@ class Check(abc.ABC):
|
|
|
198
199
|
"""Build a :class:`Finding` pre-filled from the check's metadata.
|
|
199
200
|
|
|
200
201
|
``excerpt`` overrides the evidence excerpt that is otherwise derived from
|
|
201
|
-
the first evidence record.
|
|
202
|
+
the first evidence record. ``variant`` must be set on every finding after
|
|
203
|
+
the first that the check can report at the same ``location`` in one scan,
|
|
204
|
+
so each finding keeps a distinct, stable ``uid``. A finding whose confidence is not high must
|
|
202
205
|
say why (``confidence_reason``); this is enforced so no label goes unexplained.
|
|
203
206
|
"""
|
|
204
207
|
records = [e for e in (evidence or []) if e is not None]
|
|
@@ -215,6 +218,7 @@ class Check(abc.ABC):
|
|
|
215
218
|
evidence=records,
|
|
216
219
|
evidence_excerpt=excerpt if excerpt is not None else evidence_excerpt(records),
|
|
217
220
|
location=location,
|
|
221
|
+
variant=variant,
|
|
218
222
|
details=details or {},
|
|
219
223
|
false_positive_notes=self.false_positive_notes or None,
|
|
220
224
|
confidence_reason=confidence_reason,
|
|
@@ -204,6 +204,7 @@ class Meta02RugPullBaseline(Check):
|
|
|
204
204
|
out.append(
|
|
205
205
|
self.finding(
|
|
206
206
|
title="Tools removed since baseline",
|
|
207
|
+
variant="removed",
|
|
207
208
|
description="Removed tools: " + ", ".join(tools["removed"]) + ".",
|
|
208
209
|
severity=Severity.INFO,
|
|
209
210
|
confidence=Confidence.HIGH,
|
|
@@ -206,6 +206,7 @@ class Meta03DangerousCapabilitySurface(Check):
|
|
|
206
206
|
findings.append(
|
|
207
207
|
self.finding(
|
|
208
208
|
title="High-impact tool capabilities exposed",
|
|
209
|
+
variant="high-impact",
|
|
209
210
|
description="Tools classified as high-impact: "
|
|
210
211
|
+ summary
|
|
211
212
|
+ ". Review that each is necessary, scoped and gated by user approval.",
|
|
@@ -354,6 +355,7 @@ class Meta04SchemaHygiene(Check):
|
|
|
354
355
|
findings.append(
|
|
355
356
|
self.finding(
|
|
356
357
|
title="Duplicate tool names",
|
|
358
|
+
variant="duplicate-names",
|
|
357
359
|
description="Tool names listed more than once: " + ", ".join(dupes) + ".",
|
|
358
360
|
severity=Severity.MEDIUM,
|
|
359
361
|
confidence=Confidence.HIGH,
|
|
@@ -165,6 +165,7 @@ class Err01VerboseErrors(Check):
|
|
|
165
165
|
findings.append(
|
|
166
166
|
self.finding(
|
|
167
167
|
title="Server header discloses software version",
|
|
168
|
+
variant="server-header",
|
|
168
169
|
description=f"Server: {server_header}",
|
|
169
170
|
severity=Severity.INFO,
|
|
170
171
|
confidence=Confidence.HIGH,
|
|
@@ -281,6 +282,7 @@ class Dos01UnboundedConsumption(Check):
|
|
|
281
282
|
findings.append(
|
|
282
283
|
self.finding(
|
|
283
284
|
title=f"Server struggled with {label} request",
|
|
285
|
+
variant=f"{label}-probe",
|
|
284
286
|
description=f"The {label} probe produced: {problem}. Input limits may be missing.",
|
|
285
287
|
severity=Severity.LOW if "slow" in problem else Severity.MEDIUM,
|
|
286
288
|
confidence=Confidence.MEDIUM,
|
|
@@ -360,6 +360,7 @@ class Trans03SessionIdQuality(Check):
|
|
|
360
360
|
findings.append(
|
|
361
361
|
self.finding(
|
|
362
362
|
title="Forged session ID accepted",
|
|
363
|
+
variant="forged-session-id",
|
|
363
364
|
description=(
|
|
364
365
|
"tools/list with a random, never-issued Mcp-Session-Id was answered with a "
|
|
365
366
|
"result. The server does not validate session IDs (spec: 404 for unknown sessions)."
|
|
@@ -461,6 +462,7 @@ class Trans04DeprecatedTransport(Check):
|
|
|
461
462
|
findings.append(
|
|
462
463
|
self.finding(
|
|
463
464
|
title="Pre-2025-06-18 protocol revision negotiated",
|
|
465
|
+
variant="old-revision",
|
|
464
466
|
description=f"The server negotiated protocol revision {fp.protocol_version}, "
|
|
465
467
|
"which predates the current authorization and transport requirements.",
|
|
466
468
|
severity=Severity.INFO,
|
|
@@ -521,6 +523,7 @@ class Trans05TlsAndHeaders(Check):
|
|
|
521
523
|
findings.append(
|
|
522
524
|
self.finding(
|
|
523
525
|
title="MCP endpoint served over plain HTTP",
|
|
526
|
+
variant="plain-http",
|
|
524
527
|
description=(
|
|
525
528
|
"The endpoint is reachable over unencrypted HTTP; bearer tokens, session IDs "
|
|
526
529
|
"and tool data travel in the clear."
|
|
@@ -546,6 +549,7 @@ class Trans05TlsAndHeaders(Check):
|
|
|
546
549
|
findings.append(
|
|
547
550
|
self.finding(
|
|
548
551
|
title="Weak TLS version negotiated",
|
|
552
|
+
variant="weak-tls",
|
|
549
553
|
description=f"The connection negotiated {http.tls_version}.",
|
|
550
554
|
severity=Severity.MEDIUM,
|
|
551
555
|
confidence=Confidence.HIGH,
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
``mcpprobe <target>`` is shorthand for ``mcpprobe scan <target>``.
|
|
4
4
|
|
|
5
|
-
Exit codes: 0 = no
|
|
6
|
-
above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
|
|
5
|
+
Exit codes: 0 = no finding or attack chain at or above ``--fail-on``; 1 = a
|
|
6
|
+
finding or attack chain at or above ``--fail-on``; 2 = error (unreachable target, opt-out, authorisation
|
|
7
7
|
refused, bad arguments).
|
|
8
8
|
"""
|
|
9
9
|
|
|
@@ -112,7 +112,7 @@ def _parse_kv(values: Sequence[str], what: str, sep: str) -> dict[str, str]:
|
|
|
112
112
|
default="high",
|
|
113
113
|
type=click.Choice(SEVERITIES),
|
|
114
114
|
show_default=True,
|
|
115
|
-
help="Exit 1 if any finding is at or above this severity.",
|
|
115
|
+
help="Exit 1 if any finding or attack chain is at or above this severity.",
|
|
116
116
|
)
|
|
117
117
|
@click.option(
|
|
118
118
|
"--json", "json_path", type=click.Path(dir_okay=False), help="Write the JSON report (with evidence) here."
|