mcpprobe-scanner 0.3.1__tar.gz → 0.3.2__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.2}/CHANGELOG.md +27 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/CHECKS.md +1 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/PKG-INFO +4 -4
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/README.md +3 -3
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/github-action.md +7 -7
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/registry-report.md +4 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/report-schema.md +1 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/__init__.py +1 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/action.py +55 -12
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/cli.py +3 -3
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/models.py +24 -2
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/select.py +10 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/report/json_report.py +5 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/report/sarif.py +1 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/report/terminal.py +46 -10
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_docs_and_action.py +36 -1
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_registry_report.py +35 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_reports_and_cli.py +56 -2
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/.gitignore +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/LICENSE +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/NOTICE +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/SECURITY.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/benchmark.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/judges.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/marketplace-listing.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/docs/mcp-registry.md +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/authz.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/catalogue.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/chains.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/_util.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/active_stubs.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/auth.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/base.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/baseline.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/capability.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/metadata.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/poisoning.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/proof.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/protocol_2026.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/robustness.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/tool_surface.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/checks/transport_conformance.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/dnsutil.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/environment.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/governance.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/judges/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/judges/hf.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/policy.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/py.typed +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/readiness.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/redact.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/__main__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/aggregate.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/batch.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/cli.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/common.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/fetch.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/pipeline.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/render.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/static.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/registry/template.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/report/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/scanner.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/base.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/evidence.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/fingerprint.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/http.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/ratelimit.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/mcpprobe/transport/stdio.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/pyproject.toml +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/scripts/benchmark_judge.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/__init__.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/conftest.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/data/meta01_corpus.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/data/meta01_heldout.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/data/meta01_real_benign.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/data/registry/page-1.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/data/registry/page-2.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/data/sarif-schema-2.1.0.json +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/fake_server.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/fake_stdio_server.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_checks_03.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_checks_auth.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_checks_meta.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_checks_robustness.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_checks_transport.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_environment_and_evidence.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_fixture_outcomes.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_gate_and_policy.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_judges.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_meta01_corpus.py +0 -0
- {mcpprobe_scanner-0.3.1 → mcpprobe_scanner-0.3.2}/tests/test_transport.py +0 -0
|
@@ -6,6 +6,33 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.2] - 2026-11-02
|
|
10
|
+
|
|
11
|
+
Two counting fixes. Nothing about what the checks detect has changed.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Attack chains were left out of the summary counts and the pass/fail decision. A scan
|
|
16
|
+
could list a CRITICAL `CHAIN-01` while the summary line said "critical 0", and
|
|
17
|
+
`--fail-on critical` passed (exit 0) because no single finding was critical. The
|
|
18
|
+
terminal now prints a second count line for attack chains under the findings line, for
|
|
19
|
+
example `Attack chains critical 1 high 1 ... (2 chains, built from the findings counted
|
|
20
|
+
above)`. Chains are counted on their own line, not added to the findings counts, because
|
|
21
|
+
each chain is made of findings that are already counted. The exit code and the
|
|
22
|
+
`Result:` line now take chains into account, and the `Result:` line says whether
|
|
23
|
+
findings, attack chains or both reached the threshold.
|
|
24
|
+
- The JSON summary has a new `chain_counts` field and a `findings_max_severity` field;
|
|
25
|
+
`max_severity` is now the worst of findings and attack chains (the value the exit code
|
|
26
|
+
uses). SARIF run properties have a new `chainCounts`. `counts` is unchanged: findings
|
|
27
|
+
only, one per SARIF result.
|
|
28
|
+
- The GitHub Action's annotation and job summary mention attack chains and show them in
|
|
29
|
+
their own column.
|
|
30
|
+
- Registry survey funnel: a remote URL listed more than once (by a second entry, or as
|
|
31
|
+
both streamable-http and SSE) was dropped without being counted, so `remote_urls` minus
|
|
32
|
+
the exclusions did not equal `unique_hosts` (36,687 - 9,976 = 26,711, not 26,617). These
|
|
33
|
+
are now counted as `duplicate_url`, the summary has `selected_urls`, and the funnel adds
|
|
34
|
+
up exactly. Re-run `reaggregate` to correct an existing `aggregates.json`.
|
|
35
|
+
|
|
9
36
|
## [0.3.1] - 2026-11-01
|
|
10
37
|
|
|
11
38
|
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.2 (`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.2
|
|
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.2
|
|
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.2
|
|
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.2
|
|
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
|
|
|
@@ -109,7 +109,7 @@ notifications on the analysis, never as passes.
|
|
|
109
109
|
- **Reachability.** The container runs on the runner's Docker network, so
|
|
110
110
|
`127.0.0.1` inside it is not the runner. To scan a server started earlier in the
|
|
111
111
|
same job, use the CLI instead of the Action:
|
|
112
|
-
`pipx run --spec mcpprobe-scanner==0.3.
|
|
112
|
+
`pipx run --spec mcpprobe-scanner==0.3.2 mcpprobe http://127.0.0.1:8000/mcp --sarif mcpprobe.sarif`.
|
|
113
113
|
For a remote target, it must be reachable from GitHub-hosted runners.
|
|
114
114
|
- **Private repositories** need GitHub Code Security (Advanced Security) for code
|
|
115
115
|
scanning uploads. Without it, keep the scan step as the gate and save
|
|
@@ -121,6 +121,6 @@ notifications on the analysis, never as passes.
|
|
|
121
121
|
plans to deprecate v3; `upload-sarif@v4` takes the same inputs. Switch when your
|
|
122
122
|
organisation does.
|
|
123
123
|
- **Active mode** (`extra-args: --active ...`) also needs `--authorised-by`,
|
|
124
|
-
`--scope` and `--verify dns-txt`; see the README. In 0.3.
|
|
124
|
+
`--scope` and `--verify dns-txt`; see the README. In 0.3.2 the active checks are
|
|
125
125
|
stubs.
|
|
126
|
-
- **Pin a version.** Use `@v0.3.
|
|
126
|
+
- **Pin a version.** Use `@v0.3.2` (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[]`
|
|
@@ -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
|
|
|
@@ -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."
|
|
@@ -346,12 +346,23 @@ class ScanReport(BaseModel):
|
|
|
346
346
|
notes: list[str] = Field(default_factory=list)
|
|
347
347
|
|
|
348
348
|
def counts(self) -> dict[str, int]:
|
|
349
|
-
"""Number of findings per severity (all five keys always present).
|
|
349
|
+
"""Number of findings per severity (all five keys always present).
|
|
350
|
+
|
|
351
|
+
Attack chains are not findings: each one is built from findings already
|
|
352
|
+
counted here, so they are counted separately by :meth:`chain_counts`.
|
|
353
|
+
"""
|
|
350
354
|
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
351
355
|
for f in self.findings:
|
|
352
356
|
out[f.severity.value] += 1
|
|
353
357
|
return out
|
|
354
358
|
|
|
359
|
+
def chain_counts(self) -> dict[str, int]:
|
|
360
|
+
"""Number of attack chains per severity (all five keys always present)."""
|
|
361
|
+
out = {s.value: 0 for s in _SEVERITY_ORDER}
|
|
362
|
+
for c in self.chains:
|
|
363
|
+
out[c.severity.value] += 1
|
|
364
|
+
return out
|
|
365
|
+
|
|
355
366
|
def coverage(self) -> dict[str, int]:
|
|
356
367
|
"""Check outcomes. ``passed`` counts only checks that ran and found nothing."""
|
|
357
368
|
out = {
|
|
@@ -370,7 +381,18 @@ class ScanReport(BaseModel):
|
|
|
370
381
|
return out
|
|
371
382
|
|
|
372
383
|
def max_severity(self) -> Severity | None:
|
|
373
|
-
"""Highest severity
|
|
384
|
+
"""Highest severity of any finding or attack chain, or ``None`` when there is neither.
|
|
385
|
+
|
|
386
|
+
A chain can be rated above every finding it is built from (CHAIN-01 is critical
|
|
387
|
+
while AUTH-01 and META-03 are high), so it takes part in the pass/fail decision.
|
|
388
|
+
"""
|
|
389
|
+
sevs = [f.severity for f in self.findings] + [c.severity for c in self.chains]
|
|
390
|
+
if not sevs:
|
|
391
|
+
return None
|
|
392
|
+
return max(sevs, key=lambda s: s.rank)
|
|
393
|
+
|
|
394
|
+
def findings_max_severity(self) -> Severity | None:
|
|
395
|
+
"""Highest severity among findings only, or ``None`` when there are no findings."""
|
|
374
396
|
if not self.findings:
|
|
375
397
|
return None
|
|
376
398
|
return max((f.severity for f in self.findings), key=lambda s: s.rank)
|
|
@@ -89,7 +89,12 @@ def select_targets(
|
|
|
89
89
|
*,
|
|
90
90
|
max_urls_per_host: int = 1,
|
|
91
91
|
) -> Selection:
|
|
92
|
-
"""One target per host (streamable-http preferred over SSE); every decision counted.
|
|
92
|
+
"""One target per host (streamable-http preferred over SSE); every decision counted.
|
|
93
|
+
|
|
94
|
+
Every remote URL ends up either selected or under exactly one reason in ``excluded``, so
|
|
95
|
+
``remote_urls - sum(excluded) == selected_urls``, and with ``max_urls_per_host=1``
|
|
96
|
+
``selected_urls == unique_hosts``.
|
|
97
|
+
"""
|
|
93
98
|
exclusions = exclusions or Exclusions()
|
|
94
99
|
sel = Selection()
|
|
95
100
|
by_host: dict[str, dict[str, Any]] = {}
|
|
@@ -130,6 +135,9 @@ def select_targets(
|
|
|
130
135
|
if entry["name"] not in target["entries"]:
|
|
131
136
|
target["entries"].append(entry["name"])
|
|
132
137
|
if url in target["urls"]:
|
|
138
|
+
# The same URL listed again (another entry, version or transport): count it,
|
|
139
|
+
# or the funnel would not add up (remote_urls - excluded != selected URLs).
|
|
140
|
+
sel.excluded["duplicate_url"] += 1
|
|
133
141
|
continue
|
|
134
142
|
if len(target["urls"]) >= max_urls_per_host:
|
|
135
143
|
sel.excluded["duplicate_host"] += 1
|
|
@@ -146,6 +154,7 @@ def select_targets(
|
|
|
146
154
|
"no_remote_no_package_entries": kinds["no_remote_no_package"],
|
|
147
155
|
"remote_urls": remote_urls,
|
|
148
156
|
"excluded": dict(sorted(sel.excluded.items())),
|
|
157
|
+
"selected_urls": sum(len(t["urls"]) for t in sel.targets),
|
|
149
158
|
"unique_hosts": len(sel.targets),
|
|
150
159
|
"rate_keys": len({t["rate_key"] for t in sel.targets}),
|
|
151
160
|
"max_urls_per_host": max_urls_per_host,
|
|
@@ -13,10 +13,15 @@ from mcpprobe.transport import EvidenceLog
|
|
|
13
13
|
def report_to_dict(report: ScanReport, evidence: EvidenceLog | None = None) -> dict[str, Any]:
|
|
14
14
|
data: dict[str, Any] = report.model_dump(mode="json")
|
|
15
15
|
worst = report.max_severity()
|
|
16
|
+
worst_finding = report.findings_max_severity()
|
|
16
17
|
coverage = report.coverage()
|
|
17
18
|
data["summary"] = {
|
|
19
|
+
# Findings only; chains are built from these findings and are counted separately.
|
|
18
20
|
"counts": report.counts(),
|
|
21
|
+
"chain_counts": report.chain_counts(),
|
|
22
|
+
# Worst of findings and attack chains: the value the exit code is decided on.
|
|
19
23
|
"max_severity": worst.value if worst is not None else None,
|
|
24
|
+
"findings_max_severity": worst_finding.value if worst_finding is not None else None,
|
|
20
25
|
"target_kind": report.target_kind,
|
|
21
26
|
# "passed" = ran and found nothing; not_applicable never counts as passed.
|
|
22
27
|
"checks_passed": coverage["passed"],
|
|
@@ -194,6 +194,7 @@ def to_sarif(report: ScanReport, *, location_uri: str | None = None) -> dict[str
|
|
|
194
194
|
"mode": report.mode.value,
|
|
195
195
|
"fingerprint": report.fingerprint.model_dump(mode="json") if report.fingerprint else None,
|
|
196
196
|
"counts": report.counts(),
|
|
197
|
+
"chainCounts": report.chain_counts(),
|
|
197
198
|
"targetKind": report.target_kind,
|
|
198
199
|
# Only checks that ran and found nothing are passes; not_applicable is not a pass.
|
|
199
200
|
"coverage": report.coverage(),
|
|
@@ -93,6 +93,49 @@ def _finding_block(f: Finding, verbose: bool) -> Group:
|
|
|
93
93
|
return Group(*lines)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def _count_line(label: str, counts: dict[str, int], tail: str) -> Text:
|
|
97
|
+
line = Text(label)
|
|
98
|
+
for sev in _ORDER:
|
|
99
|
+
style = severity_style(sev, badge=True) if counts[sev.value] else "dim"
|
|
100
|
+
line.append(f" {sev.value} {counts[sev.value]} ", style=style)
|
|
101
|
+
line.append(" ")
|
|
102
|
+
line.append(tail, style="dim")
|
|
103
|
+
return line
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def summary_lines(report: ScanReport) -> list[Text]:
|
|
107
|
+
"""The per-severity count lines: findings, then attack chains when there are any.
|
|
108
|
+
|
|
109
|
+
Chains are counted on their own line because each one is built from findings that
|
|
110
|
+
are already counted; adding them to the findings counts would count those twice.
|
|
111
|
+
"""
|
|
112
|
+
counts = report.counts()
|
|
113
|
+
total = sum(counts.values())
|
|
114
|
+
if not report.chains:
|
|
115
|
+
return [_count_line("Findings ", counts, f"({total} {'finding' if total == 1 else 'findings'})")]
|
|
116
|
+
chain_counts = report.chain_counts()
|
|
117
|
+
n = sum(chain_counts.values())
|
|
118
|
+
return [
|
|
119
|
+
_count_line("Findings ", counts, f"({total} {'finding' if total == 1 else 'findings'})"),
|
|
120
|
+
_count_line(
|
|
121
|
+
"Attack chains ",
|
|
122
|
+
chain_counts,
|
|
123
|
+
f"({n} {'chain' if n == 1 else 'chains'}, built from the findings counted above)",
|
|
124
|
+
),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fail_reason(report: ScanReport, fail_on: Severity) -> str:
|
|
129
|
+
"""Why the result is FAIL: findings, attack chains, or both, at or above ``fail_on``."""
|
|
130
|
+
findings = any(f.severity >= fail_on for f in report.findings)
|
|
131
|
+
chains = any(c.severity >= fail_on for c in report.chains)
|
|
132
|
+
if findings and chains:
|
|
133
|
+
return f"findings and attack chains at or above {fail_on.value}"
|
|
134
|
+
if chains:
|
|
135
|
+
return f"attack chain at or above {fail_on.value}"
|
|
136
|
+
return f"findings at or above {fail_on.value}"
|
|
137
|
+
|
|
138
|
+
|
|
96
139
|
def render(
|
|
97
140
|
report: ScanReport,
|
|
98
141
|
console: Console | None = None,
|
|
@@ -136,13 +179,8 @@ def render(
|
|
|
136
179
|
console.print(Text("No check was run against this target; nothing passed.", style="dim"))
|
|
137
180
|
return
|
|
138
181
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
for sev in _ORDER:
|
|
142
|
-
style = severity_style(sev, badge=True) if counts[sev.value] else "dim"
|
|
143
|
-
summary.append(f" {sev.value} {counts[sev.value]} ", style=style)
|
|
144
|
-
summary.append(" ")
|
|
145
|
-
console.print(summary)
|
|
182
|
+
for line in summary_lines(report):
|
|
183
|
+
console.print(line)
|
|
146
184
|
console.print()
|
|
147
185
|
|
|
148
186
|
for chain in report.chains:
|
|
@@ -212,9 +250,7 @@ def render(
|
|
|
212
250
|
if fail_on is not None:
|
|
213
251
|
top = report.max_severity()
|
|
214
252
|
if top is not None and top >= fail_on:
|
|
215
|
-
console.print(
|
|
216
|
-
Text(f"Result: FAIL (findings at or above {fail_on.value})", style=severity_style(top))
|
|
217
|
-
)
|
|
253
|
+
console.print(Text(f"Result: FAIL ({fail_reason(report, fail_on)})", style=severity_style(top)))
|
|
218
254
|
else:
|
|
219
255
|
console.print(
|
|
220
256
|
Text(f"Result: PASS (nothing at or above {fail_on.value})", style=f"bold {PASS_HEX}")
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
9
|
-
from mcpprobe.action import build_argv, describe, run
|
|
10
|
+
from mcpprobe.action import _summary, build_argv, describe, run, sarif_chain_counts, sarif_counts
|
|
10
11
|
from mcpprobe.catalogue import checks_markdown
|
|
11
12
|
from mcpprobe.checks import ALL_CHECKS
|
|
12
13
|
from mcpprobe.cli import EXIT_CLEAN, EXIT_ERROR, EXIT_FINDINGS, EXIT_NOT_MCP, main
|
|
@@ -110,6 +111,40 @@ def test_action_describe_every_exit_code():
|
|
|
110
111
|
assert describe(EXIT_ERROR, "u", "high", {})[1] == "mcpprobe: scan error"
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
def test_action_counts_attack_chains_separately(tmp_path):
|
|
115
|
+
sarif = tmp_path / "r.sarif"
|
|
116
|
+
sarif.write_text(
|
|
117
|
+
json.dumps(
|
|
118
|
+
{
|
|
119
|
+
"runs": [
|
|
120
|
+
{
|
|
121
|
+
"results": [
|
|
122
|
+
{"properties": {"severity": "high"}},
|
|
123
|
+
{"properties": {"severity": "high"}},
|
|
124
|
+
],
|
|
125
|
+
"properties": {"chains": [{"id": "CHAIN-01", "severity": "critical"}]},
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
counts, chains = sarif_counts(sarif), sarif_chain_counts(sarif)
|
|
132
|
+
assert counts["high"] == 2 and sum(counts.values()) == 2
|
|
133
|
+
assert chains["critical"] == 1 and sum(chains.values()) == 1
|
|
134
|
+
level, _, message = describe(EXIT_FINDINGS, "u", "critical", counts, chains)
|
|
135
|
+
assert level == "error"
|
|
136
|
+
assert "2 finding(s) on u (2 high) and 1 attack chain(s) built from them (1 critical)" in message
|
|
137
|
+
md = _summary(EXIT_FINDINGS, "u", "critical", counts, message, chains)
|
|
138
|
+
assert "| Severity | Findings | Attack chains |" in md and "| critical | 0 | 1 |" in md
|
|
139
|
+
assert "| high | 2 | 0 |" in md
|
|
140
|
+
_, _, clean = describe(EXIT_CLEAN, "u", "critical", {"high": 1}, {"high": 1})
|
|
141
|
+
assert (
|
|
142
|
+
clean
|
|
143
|
+
== "No finding or attack chain at or above 'critical' on u (1 high, attack chains: 1 high below the threshold)."
|
|
144
|
+
)
|
|
145
|
+
assert sarif_chain_counts(tmp_path / "missing.sarif") == {}
|
|
146
|
+
|
|
147
|
+
|
|
113
148
|
def test_main_accepts_options_before_target():
|
|
114
149
|
with pytest.raises(SystemExit) as exc:
|
|
115
150
|
main(["--stdio", " ".join(stdio_command()), "--only", "META-01", "-q"])
|
|
@@ -253,6 +253,41 @@ def test_select_exclusion_list_and_local_names():
|
|
|
253
253
|
assert sel.summary["package_only_entries"] == 1
|
|
254
254
|
|
|
255
255
|
|
|
256
|
+
def test_select_funnel_adds_up_with_repeated_urls():
|
|
257
|
+
"""remote_urls - sum(excluded) == unique_hosts: a repeated URL is counted, not dropped silently."""
|
|
258
|
+
same = "https://same.example.com/mcp"
|
|
259
|
+
entries = [
|
|
260
|
+
{"name": "b/1", "remotes": [{"type": "streamable-http", "url": same}]},
|
|
261
|
+
# The same URL listed by a second entry, and again as SSE by a third.
|
|
262
|
+
{"name": "b/2", "remotes": [{"type": "streamable-http", "url": same}]},
|
|
263
|
+
{"name": "b/3", "remotes": [{"type": "streamable-http", "url": same}, {"type": "sse", "url": same}]},
|
|
264
|
+
# Another URL on a host already selected.
|
|
265
|
+
{"name": "b/4", "remotes": [{"type": "sse", "url": "https://same.example.com/sse"}]},
|
|
266
|
+
{"name": "b/5", "remotes": [{"type": "streamable-http", "url": "https://other.example.net/mcp"}]},
|
|
267
|
+
{"name": "b/6", "remotes": [{"type": "streamable-http", "url": "https://{tenant}.example.com/mcp"}]},
|
|
268
|
+
{"name": "b/7", "remotes": [{"type": "streamable-http", "url": "http://10.0.0.1/mcp"}]},
|
|
269
|
+
{"name": "b/8", "remotes": [{"type": "streamable-http", "url": "https://svc.local/mcp"}]},
|
|
270
|
+
]
|
|
271
|
+
s = select_targets(entries).summary
|
|
272
|
+
assert s["remote_urls"] == 9
|
|
273
|
+
assert s["excluded"] == {
|
|
274
|
+
"duplicate_url": 3,
|
|
275
|
+
"duplicate_host": 1,
|
|
276
|
+
"templated_url": 1,
|
|
277
|
+
"ip_literal": 1,
|
|
278
|
+
"local_name": 1,
|
|
279
|
+
}
|
|
280
|
+
assert s["unique_hosts"] == s["selected_urls"] == 2
|
|
281
|
+
assert s["remote_urls"] - sum(s["excluded"].values()) == s["unique_hosts"]
|
|
282
|
+
same_target = next(t for t in select_targets(entries).targets if t["host"] == "same.example.com")
|
|
283
|
+
assert same_target["entries"] == ["b/1", "b/2", "b/3", "b/4"]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_select_funnel_adds_up_on_fixture_registry(pipeline):
|
|
287
|
+
s = pipeline["res"].selection.summary
|
|
288
|
+
assert s["remote_urls"] - sum(s["excluded"].values()) == s["unique_hosts"] == s["selected_urls"]
|
|
289
|
+
|
|
290
|
+
|
|
256
291
|
def test_static_checks_on_registry_tool_definitions(pipeline):
|
|
257
292
|
static = {r["name"]: r for r in read_jsonl(pipeline["wd"].static)}
|
|
258
293
|
assert len(static) == 10
|
|
@@ -113,8 +113,11 @@ async def test_json_report_shape(make_server):
|
|
|
113
113
|
async with make_server(preset="vulnerable") as srv:
|
|
114
114
|
report = await run(srv.url)
|
|
115
115
|
data = report_to_dict(report)
|
|
116
|
-
|
|
116
|
+
# CHAIN-01 is critical while every finding is at most high: the overall worst includes chains.
|
|
117
|
+
assert data["summary"]["max_severity"] == "critical"
|
|
118
|
+
assert data["summary"]["findings_max_severity"] == "high"
|
|
117
119
|
assert sum(data["summary"]["counts"].values()) == len(data["findings"]) > 10
|
|
120
|
+
assert sum(data["summary"]["chain_counts"].values()) == len(data["chains"]) >= 1
|
|
118
121
|
assert [a["id"] for a in data["attestations"]] == ["GOV-01", "GOV-02", "GOV-03", "GOV-04", "GOV-05"]
|
|
119
122
|
assert all(a["status"] == "attestation-only" for a in data["attestations"])
|
|
120
123
|
for f in data["findings"]:
|
|
@@ -214,9 +217,15 @@ def test_cli_exit_1_on_findings_and_writes_reports(live_server, tmp_path):
|
|
|
214
217
|
|
|
215
218
|
|
|
216
219
|
def test_cli_fail_on_threshold(live_server):
|
|
220
|
+
# Every finding on loopback is at most high, so only the attack chains decide here.
|
|
217
221
|
with live_server("vulnerable") as srv:
|
|
222
|
+
res = invoke("scan", srv.url, "--rate", "10", "--fail-on", "critical")
|
|
223
|
+
# 0.3.1 exited 0 here while listing a CRITICAL CHAIN-01; the exit code now counts chains.
|
|
224
|
+
assert res.exit_code == EXIT_FINDINGS, res.output
|
|
225
|
+
assert "Result: FAIL (attack chain at or above critical)" in res.output
|
|
226
|
+
with live_server("vulnerable", require_auth=True) as srv:
|
|
218
227
|
res = invoke("scan", srv.url, "--rate", "10", "--fail-on", "critical", "-q")
|
|
219
|
-
assert res.exit_code == EXIT_CLEAN #
|
|
228
|
+
assert res.exit_code == EXIT_CLEAN, res.output # no AUTH-01, so no chain and nothing critical
|
|
220
229
|
|
|
221
230
|
|
|
222
231
|
def test_cli_exit_0_clean(live_server):
|
|
@@ -354,3 +363,48 @@ def test_cli_token_command():
|
|
|
354
363
|
res = invoke("token", "mcp.example.com")
|
|
355
364
|
assert res.exit_code == 0
|
|
356
365
|
assert "_mcpprobe.mcp.example.com. TXT" in res.output and "mcpprobe-verification=" in res.output
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ----------------------------------------------------------------------------- counts and chains
|
|
369
|
+
async def test_counts_and_chains_consistent_across_outputs(make_server):
|
|
370
|
+
"""Every severity the report lists is counted somewhere, the same way in every output."""
|
|
371
|
+
async with make_server(preset="vulnerable") as srv:
|
|
372
|
+
report = await run(srv.url)
|
|
373
|
+
assert any(c.id == "CHAIN-01" and c.severity == Severity.CRITICAL for c in report.chains)
|
|
374
|
+
assert all(f.severity < Severity.CRITICAL for f in report.findings)
|
|
375
|
+
counts, chain_counts = report.counts(), report.chain_counts()
|
|
376
|
+
for sev in Severity:
|
|
377
|
+
assert counts[sev.value] == sum(1 for f in report.findings if f.severity == sev)
|
|
378
|
+
assert chain_counts[sev.value] == sum(1 for c in report.chains if c.severity == sev)
|
|
379
|
+
assert chain_counts["critical"] >= 1
|
|
380
|
+
assert report.max_severity() == Severity.CRITICAL
|
|
381
|
+
assert report.findings_max_severity() == Severity.HIGH
|
|
382
|
+
|
|
383
|
+
data = report_to_dict(report)
|
|
384
|
+
assert data["summary"]["counts"] == counts and data["summary"]["chain_counts"] == chain_counts
|
|
385
|
+
sarif = to_sarif(report)
|
|
386
|
+
props = sarif["runs"][0]["properties"]
|
|
387
|
+
assert props["counts"] == counts and props["chainCounts"] == chain_counts
|
|
388
|
+
assert len(sarif["runs"][0]["results"]) == sum(counts.values())
|
|
389
|
+
|
|
390
|
+
console = Console(record=True, width=200)
|
|
391
|
+
render(report, console, fail_on=Severity.CRITICAL)
|
|
392
|
+
text = console.export_text()
|
|
393
|
+
findings_line = next(line for line in text.splitlines() if line.startswith("Findings"))
|
|
394
|
+
chains_line = next(line for line in text.splitlines() if line.startswith("Attack chains"))
|
|
395
|
+
for sev in Severity:
|
|
396
|
+
assert f" {sev.value} {counts[sev.value]} " in findings_line
|
|
397
|
+
assert f" {sev.value} {chain_counts[sev.value]} " in chains_line
|
|
398
|
+
assert f"({sum(counts.values())} findings)" in findings_line
|
|
399
|
+
# The chain is the only thing at critical, so the verdict must name it.
|
|
400
|
+
assert "Result: FAIL (attack chain at or above critical)" in text
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def test_terminal_has_no_chain_line_without_chains(make_server):
|
|
404
|
+
async with make_server() as srv:
|
|
405
|
+
report = await run(srv.url, only=["META-04"])
|
|
406
|
+
assert report.chains == []
|
|
407
|
+
console = Console(record=True, width=200)
|
|
408
|
+
render(report, console, fail_on=Severity.HIGH)
|
|
409
|
+
text = console.export_text()
|
|
410
|
+
assert "Attack chains" not in text and "Result: PASS (nothing at or above high)" in text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|