mcpprobe-scanner 0.1.0__py3-none-any.whl
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/__init__.py +21 -0
- mcpprobe/action.py +69 -0
- mcpprobe/authz.py +313 -0
- mcpprobe/catalogue.py +125 -0
- mcpprobe/checks/__init__.py +100 -0
- mcpprobe/checks/_util.py +102 -0
- mcpprobe/checks/active_stubs.py +212 -0
- mcpprobe/checks/auth.py +473 -0
- mcpprobe/checks/base.py +228 -0
- mcpprobe/checks/baseline.py +219 -0
- mcpprobe/checks/capability.py +171 -0
- mcpprobe/checks/metadata.py +341 -0
- mcpprobe/checks/poisoning.py +480 -0
- mcpprobe/checks/robustness.py +266 -0
- mcpprobe/checks/transport_conformance.py +429 -0
- mcpprobe/cli.py +295 -0
- mcpprobe/dnsutil.py +67 -0
- mcpprobe/governance.py +74 -0
- mcpprobe/models.py +264 -0
- mcpprobe/policy.py +140 -0
- mcpprobe/py.typed +0 -0
- mcpprobe/redact.py +101 -0
- mcpprobe/report/__init__.py +7 -0
- mcpprobe/report/json_report.py +33 -0
- mcpprobe/report/sarif.py +329 -0
- mcpprobe/report/terminal.py +172 -0
- mcpprobe/scanner.py +211 -0
- mcpprobe/transport/__init__.py +34 -0
- mcpprobe/transport/base.py +156 -0
- mcpprobe/transport/evidence.py +135 -0
- mcpprobe/transport/fingerprint.py +154 -0
- mcpprobe/transport/http.py +366 -0
- mcpprobe/transport/ratelimit.py +47 -0
- mcpprobe/transport/stdio.py +201 -0
- mcpprobe_scanner-0.1.0.dist-info/METADATA +240 -0
- mcpprobe_scanner-0.1.0.dist-info/RECORD +39 -0
- mcpprobe_scanner-0.1.0.dist-info/WHEEL +4 -0
- mcpprobe_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
mcpprobe/policy.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Scanner identification and opt-out handling.
|
|
2
|
+
|
|
3
|
+
* Every request carries ``User-Agent: mcpprobe/<version> (+https://mcpprobe.tech/scanning-policy)``.
|
|
4
|
+
* Before **any** request is sent to an HTTP target the scanner checks two
|
|
5
|
+
opt-out signals, in this order, and aborts if either is present:
|
|
6
|
+
|
|
7
|
+
1. DNS: a TXT record ``mcpprobe-opt-out=all`` at ``_mcpprobe.<host>`` (and at
|
|
8
|
+
``_mcpprobe.<parent>`` for each parent domain with at least two labels, so
|
|
9
|
+
an operator can opt out a whole zone with one record). This is checked
|
|
10
|
+
before any packet reaches the target itself.
|
|
11
|
+
2. HTTP: ``GET /.well-known/mcpprobe-opt-out`` on the target's origin. A
|
|
12
|
+
``200`` response means the operator does not want to be scanned.
|
|
13
|
+
|
|
14
|
+
Neither check can be disabled from the command line.
|
|
15
|
+
* All traffic passes through one rate limiter (default 5 requests/second,
|
|
16
|
+
hard maximum 10).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
24
|
+
|
|
25
|
+
from mcpprobe import POLICY_URL, USER_AGENT
|
|
26
|
+
from mcpprobe.dnsutil import (
|
|
27
|
+
DnsLookupError,
|
|
28
|
+
TxtLookup,
|
|
29
|
+
default_txt_lookup,
|
|
30
|
+
has_public_dns_name,
|
|
31
|
+
record_name,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # avoid an import cycle: checks -> policy -> transport
|
|
35
|
+
from mcpprobe.transport import HttpTransport
|
|
36
|
+
|
|
37
|
+
OPT_OUT_PATH = "/.well-known/mcpprobe-opt-out"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def origin_of(url: str) -> str:
|
|
41
|
+
parts = urlsplit(url)
|
|
42
|
+
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def host_of(url: str) -> str:
|
|
46
|
+
return urlsplit(url).hostname or ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
OPT_OUT_TXT = "mcpprobe-opt-out=all"
|
|
50
|
+
#: Hard ceiling on requests per second, whatever the operator asks for.
|
|
51
|
+
MAX_RATE = 10.0
|
|
52
|
+
DEFAULT_RATE = 5.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OptOutError(Exception):
|
|
56
|
+
"""The target host has published an opt-out signal."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def opt_out_url(target: str) -> str:
|
|
60
|
+
return origin_of(target) + OPT_OUT_PATH
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def opt_out_dns_names(host: str) -> list[str]:
|
|
64
|
+
"""``_mcpprobe.<host>`` then each parent with >= 2 labels (max 3 names)."""
|
|
65
|
+
labels = host.rstrip(".").lower().split(".")
|
|
66
|
+
names = [record_name(".".join(labels[i:])) for i in range(len(labels) - 1)]
|
|
67
|
+
return names[:3]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_opt_out_record(value: str) -> bool:
|
|
71
|
+
return value.strip().strip('"').lower().replace(" ", "") == OPT_OUT_TXT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def check_dns_opt_out(host: str, lookup: TxtLookup | None = None) -> list[str]:
|
|
75
|
+
"""Raise :class:`OptOutError` if a DNS opt-out record exists. Returns notes for the report."""
|
|
76
|
+
notes: list[str] = []
|
|
77
|
+
if not has_public_dns_name(host):
|
|
78
|
+
notes.append(f"opt-out: DNS check skipped for {host or 'target'} (no public DNS name)")
|
|
79
|
+
return notes
|
|
80
|
+
resolver = lookup or default_txt_lookup
|
|
81
|
+
for name in opt_out_dns_names(host):
|
|
82
|
+
try:
|
|
83
|
+
records = await asyncio.to_thread(resolver, name)
|
|
84
|
+
except DnsLookupError as exc:
|
|
85
|
+
notes.append(f"opt-out: {exc}; continuing with the HTTP opt-out check")
|
|
86
|
+
continue
|
|
87
|
+
if any(_is_opt_out_record(r) for r in records):
|
|
88
|
+
raise OptOutError(
|
|
89
|
+
f"{name} publishes TXT {OPT_OUT_TXT!r}; the operator of {host} has opted out of "
|
|
90
|
+
f"mcpprobe scans. No request was sent to the target. See {POLICY_URL}."
|
|
91
|
+
)
|
|
92
|
+
notes.append(f"opt-out: no DNS opt-out record for {host}")
|
|
93
|
+
return notes
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def check_http_opt_out(http: HttpTransport, target: str) -> None:
|
|
97
|
+
"""Raise :class:`OptOutError` if the host serves the opt-out document."""
|
|
98
|
+
url = opt_out_url(target)
|
|
99
|
+
resp = await http.get(url, note="opt-out check", label="GET(opt-out)")
|
|
100
|
+
if resp.status == 200:
|
|
101
|
+
body = resp.body.decode("utf-8", errors="replace").strip()
|
|
102
|
+
excerpt = body.splitlines()[0][:200] if body else ""
|
|
103
|
+
raise OptOutError(
|
|
104
|
+
f"{origin_of(target)} publishes {OPT_OUT_PATH}; scanning is not permitted. "
|
|
105
|
+
+ (f"Message from host: {excerpt!r}. " if excerpt else "")
|
|
106
|
+
+ f"See {POLICY_URL}."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def check_opt_out(http: HttpTransport, target: str, lookup: TxtLookup | None = None) -> list[str]:
|
|
111
|
+
"""Run both opt-out checks (DNS first). Raises :class:`OptOutError`; returns report notes."""
|
|
112
|
+
notes = await check_dns_opt_out(host_of(target), lookup)
|
|
113
|
+
await check_http_opt_out(http, target)
|
|
114
|
+
return notes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clamp_rate(rate: float) -> float:
|
|
118
|
+
"""Validate a requested rate against the hard maximum."""
|
|
119
|
+
if rate <= 0:
|
|
120
|
+
raise ValueError("rate must be positive")
|
|
121
|
+
if rate > MAX_RATE:
|
|
122
|
+
raise ValueError(f"rate {rate:g} req/s exceeds the maximum of {MAX_RATE:g} req/s")
|
|
123
|
+
return rate
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"DEFAULT_RATE",
|
|
128
|
+
"MAX_RATE",
|
|
129
|
+
"OPT_OUT_PATH",
|
|
130
|
+
"OPT_OUT_TXT",
|
|
131
|
+
"POLICY_URL",
|
|
132
|
+
"USER_AGENT",
|
|
133
|
+
"OptOutError",
|
|
134
|
+
"check_dns_opt_out",
|
|
135
|
+
"check_http_opt_out",
|
|
136
|
+
"check_opt_out",
|
|
137
|
+
"clamp_rate",
|
|
138
|
+
"opt_out_dns_names",
|
|
139
|
+
"opt_out_url",
|
|
140
|
+
]
|
mcpprobe/py.typed
ADDED
|
File without changes
|
mcpprobe/redact.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Secret patterns and redaction helpers.
|
|
2
|
+
|
|
3
|
+
Shared by META-05 (which reports secrets found in server metadata) and the
|
|
4
|
+
evidence log (which must never re-publish a credential in a JSON or SARIF
|
|
5
|
+
report that may end up in CI logs or GitHub code scanning).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
from mcpprobe.models import Severity
|
|
14
|
+
|
|
15
|
+
#: (label, pattern, severity). When a pattern has a capture group, group 1 is the secret value.
|
|
16
|
+
SECRET_PATTERNS: list[tuple[str, re.Pattern[str], Severity]] = [
|
|
17
|
+
(
|
|
18
|
+
"private key block",
|
|
19
|
+
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----"),
|
|
20
|
+
Severity.HIGH,
|
|
21
|
+
),
|
|
22
|
+
("AWS access key id", re.compile(r"(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])"), Severity.HIGH),
|
|
23
|
+
(
|
|
24
|
+
"GitHub token",
|
|
25
|
+
re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
|
|
26
|
+
Severity.HIGH,
|
|
27
|
+
),
|
|
28
|
+
("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), Severity.HIGH),
|
|
29
|
+
("Stripe key", re.compile(r"\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b"), Severity.HIGH),
|
|
30
|
+
("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), Severity.HIGH),
|
|
31
|
+
("generic sk- API key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b"), Severity.HIGH),
|
|
32
|
+
(
|
|
33
|
+
"JWT",
|
|
34
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{0,}"),
|
|
35
|
+
Severity.MEDIUM,
|
|
36
|
+
),
|
|
37
|
+
("bearer token", re.compile(r"\bbearer\s+([A-Za-z0-9\-._~+/]{20,}=*)", re.I), Severity.MEDIUM),
|
|
38
|
+
(
|
|
39
|
+
"connection string with password",
|
|
40
|
+
re.compile(
|
|
41
|
+
r"\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|mssql)://[^:\s/]+:([^@\s]+)@", re.I
|
|
42
|
+
),
|
|
43
|
+
Severity.HIGH,
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"credential assignment",
|
|
47
|
+
re.compile(
|
|
48
|
+
r"\b(?:api[_-]?key|secret(?:[_-]?key)?|token|password|passwd|pwd|auth[_-]?key|"
|
|
49
|
+
r"access[_-]?key|client[_-]?secret)\b"
|
|
50
|
+
r"\s*[:=]\s*['\"]?([A-Za-z0-9_\-/+=.]{16,})",
|
|
51
|
+
re.I,
|
|
52
|
+
),
|
|
53
|
+
Severity.MEDIUM,
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"secret after keyword",
|
|
57
|
+
re.compile(r"\bsecret\s+([A-Za-z0-9/+=]{30,})", re.I),
|
|
58
|
+
Severity.MEDIUM,
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
_SENSITIVE_QUERY_KEYS = frozenset({"access_token", "token", "api_key", "apikey", "key", "secret", "password"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def redact(value: str) -> str:
|
|
66
|
+
"""Keep a short prefix/suffix so a human can recognise the value without it being usable."""
|
|
67
|
+
if len(value) <= 8:
|
|
68
|
+
return "*" * len(value)
|
|
69
|
+
return f"{value[:4]}…{value[-2:]} ({len(value)} chars)"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def redact_secrets(text: str) -> str:
|
|
73
|
+
"""Replace every known secret pattern match in ``text`` with its redacted form."""
|
|
74
|
+
out = text
|
|
75
|
+
for _label, pattern, _sev in SECRET_PATTERNS:
|
|
76
|
+
|
|
77
|
+
def _sub(m: re.Match[str]) -> str:
|
|
78
|
+
if m.lastindex:
|
|
79
|
+
start, end = m.span(1)
|
|
80
|
+
whole_start = m.start(0)
|
|
81
|
+
g = m.group(0)
|
|
82
|
+
return g[: start - whole_start] + redact(m.group(1)) + g[end - whole_start :]
|
|
83
|
+
return redact(m.group(0))
|
|
84
|
+
|
|
85
|
+
out = pattern.sub(_sub, out)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def redact_url(url: str) -> str:
|
|
90
|
+
"""Redact credential-like query parameters in a URL."""
|
|
91
|
+
parts = urlsplit(url)
|
|
92
|
+
if not parts.query:
|
|
93
|
+
return url
|
|
94
|
+
pairs = parse_qsl(parts.query, keep_blank_values=True)
|
|
95
|
+
if not any(k.lower() in _SENSITIVE_QUERY_KEYS for k, _ in pairs):
|
|
96
|
+
return url
|
|
97
|
+
cleaned = [(k, "<redacted>" if k.lower() in _SENSITIVE_QUERY_KEYS else v) for k, v in pairs]
|
|
98
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(cleaned), parts.fragment))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["SECRET_PATTERNS", "redact", "redact_secrets", "redact_url"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Report writers: JSON, SARIF 2.1.0 and a rich terminal summary."""
|
|
2
|
+
|
|
3
|
+
from mcpprobe.report.json_report import report_to_dict, write_json
|
|
4
|
+
from mcpprobe.report.sarif import to_sarif, write_sarif
|
|
5
|
+
from mcpprobe.report.terminal import render
|
|
6
|
+
|
|
7
|
+
__all__ = ["render", "report_to_dict", "to_sarif", "write_json", "write_sarif"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""JSON report writer: a faithful dump of the ScanReport plus the evidence log."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcpprobe.models import ScanReport
|
|
10
|
+
from mcpprobe.transport import EvidenceLog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def report_to_dict(report: ScanReport, evidence: EvidenceLog | None = None) -> dict[str, Any]:
|
|
14
|
+
data: dict[str, Any] = report.model_dump(mode="json")
|
|
15
|
+
worst = report.max_severity()
|
|
16
|
+
data["summary"] = {
|
|
17
|
+
"counts": report.counts(),
|
|
18
|
+
"max_severity": worst.value if worst is not None else None,
|
|
19
|
+
"checks_ran": sum(1 for c in report.checks if c.status.value == "ran"),
|
|
20
|
+
"checks_skipped": sum(1 for c in report.checks if c.status.value == "skipped"),
|
|
21
|
+
"checks_not_implemented": sum(1 for c in report.checks if c.status.value == "not_implemented"),
|
|
22
|
+
"checks_error": sum(1 for c in report.checks if c.status.value == "error"),
|
|
23
|
+
}
|
|
24
|
+
if evidence is not None:
|
|
25
|
+
data["evidence"] = [rec.model_dump(mode="json") for rec in evidence.records]
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_json(report: ScanReport, path: str | Path, evidence: EvidenceLog | None = None) -> Path:
|
|
30
|
+
out = Path(path)
|
|
31
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
out.write_text(json.dumps(report_to_dict(report, evidence), indent=2, ensure_ascii=False), "utf-8")
|
|
33
|
+
return out
|
mcpprobe/report/sarif.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""SARIF 2.1.0 output for GitHub code scanning and other SARIF consumers.
|
|
2
|
+
|
|
3
|
+
Severity mapping: critical/high -> ``error``, medium -> ``warning``,
|
|
4
|
+
low/info -> ``note``. The numeric ``security-severity`` rule property follows
|
|
5
|
+
the GitHub convention (critical 9.0+, high 7.0-8.9, medium 4.0-6.9, low <4.0).
|
|
6
|
+
|
|
7
|
+
Results need a ``physicalLocation``. A scanned endpoint is not a file in the
|
|
8
|
+
repository, so by default the location URI is the target URL (or a
|
|
9
|
+
``stdio:`` URI for a local command). Pass ``location_uri`` (CLI
|
|
10
|
+
``--sarif-location``) to anchor results to a repository file instead, e.g. the
|
|
11
|
+
server's entry point or the client config that references it.
|
|
12
|
+
|
|
13
|
+
:func:`validate_sarif` checks the structural requirements of the SARIF 2.1.0
|
|
14
|
+
specification that matter for consumers (required properties, enumerations,
|
|
15
|
+
index consistency, no ``null`` values). The test-suite additionally validates
|
|
16
|
+
against the official JSON schema.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
from mcpprobe import POLICY_URL, __version__
|
|
27
|
+
from mcpprobe.checks import ALL_CHECKS
|
|
28
|
+
from mcpprobe.models import Finding, ScanReport, Severity
|
|
29
|
+
|
|
30
|
+
SARIF_VERSION = "2.1.0"
|
|
31
|
+
SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
32
|
+
INFORMATION_URI = "https://mcpprobe.tech"
|
|
33
|
+
|
|
34
|
+
LEVEL: dict[Severity, str] = {
|
|
35
|
+
Severity.CRITICAL: "error",
|
|
36
|
+
Severity.HIGH: "error",
|
|
37
|
+
Severity.MEDIUM: "warning",
|
|
38
|
+
Severity.LOW: "note",
|
|
39
|
+
Severity.INFO: "note",
|
|
40
|
+
}
|
|
41
|
+
SECURITY_SEVERITY: dict[Severity, str] = {
|
|
42
|
+
Severity.CRITICAL: "9.5",
|
|
43
|
+
Severity.HIGH: "7.5",
|
|
44
|
+
Severity.MEDIUM: "5.0",
|
|
45
|
+
Severity.LOW: "2.5",
|
|
46
|
+
Severity.INFO: "0.5",
|
|
47
|
+
}
|
|
48
|
+
_LEVELS = {"none", "note", "warning", "error"}
|
|
49
|
+
_KINDS = {"notApplicable", "pass", "fail", "review", "open", "informational"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rules() -> list[dict[str, Any]]:
|
|
53
|
+
rules = []
|
|
54
|
+
for cls in ALL_CHECKS:
|
|
55
|
+
fp_note = f"\n\nFalse positives: {cls.false_positive_notes}" if cls.false_positive_notes else ""
|
|
56
|
+
rules.append(
|
|
57
|
+
{
|
|
58
|
+
"id": cls.id,
|
|
59
|
+
"name": cls.__name__,
|
|
60
|
+
"shortDescription": {"text": cls.title},
|
|
61
|
+
"fullDescription": {"text": cls.description or cls.title},
|
|
62
|
+
"helpUri": f"{INFORMATION_URI}/checks/{cls.id.lower()}",
|
|
63
|
+
"help": {
|
|
64
|
+
"text": (cls.fix or cls.title) + fp_note,
|
|
65
|
+
"markdown": f"**Fix:** {cls.fix or cls.title}"
|
|
66
|
+
+ (
|
|
67
|
+
f"\n\n**False positives:** {cls.false_positive_notes}"
|
|
68
|
+
if cls.false_positive_notes
|
|
69
|
+
else ""
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
"defaultConfiguration": {"level": LEVEL[cls.severity]},
|
|
73
|
+
"properties": {
|
|
74
|
+
"tags": _unique(["security", "mcp", cls.mode.value, *cls.standards.as_tags()]),
|
|
75
|
+
"security-severity": SECURITY_SEVERITY[cls.severity],
|
|
76
|
+
"precision": "medium",
|
|
77
|
+
"problem.severity": "error" if LEVEL[cls.severity] == "error" else "warning",
|
|
78
|
+
"mode": cls.mode.value,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return rules
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _unique(items: list[str]) -> list[str]:
|
|
86
|
+
seen: set[str] = set()
|
|
87
|
+
out = []
|
|
88
|
+
for i in items:
|
|
89
|
+
if i not in seen:
|
|
90
|
+
seen.add(i)
|
|
91
|
+
out.append(i)
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def default_location_uri(report: ScanReport) -> str:
|
|
96
|
+
if report.fingerprint is not None and report.fingerprint.transport == "stdio":
|
|
97
|
+
return "stdio:" + quote(report.target, safe="")
|
|
98
|
+
return report.target
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _result(f: Finding, rule_index: dict[str, int], uri: str) -> dict[str, Any]:
|
|
102
|
+
text = f.description
|
|
103
|
+
if f.evidence_excerpt:
|
|
104
|
+
text += f"\n\nEvidence: {f.evidence_excerpt}"
|
|
105
|
+
if f.fix:
|
|
106
|
+
text += f"\n\nFix: {f.fix}"
|
|
107
|
+
result: dict[str, Any] = {
|
|
108
|
+
"ruleId": f.id,
|
|
109
|
+
"level": LEVEL[f.severity],
|
|
110
|
+
"kind": "fail",
|
|
111
|
+
"message": {"text": text},
|
|
112
|
+
"locations": [
|
|
113
|
+
{
|
|
114
|
+
"physicalLocation": {
|
|
115
|
+
"artifactLocation": {"uri": uri},
|
|
116
|
+
"region": {"startLine": 1},
|
|
117
|
+
},
|
|
118
|
+
"logicalLocations": [{"name": f.location or uri, "kind": "resource"}],
|
|
119
|
+
}
|
|
120
|
+
],
|
|
121
|
+
"partialFingerprints": {"mcpprobe/v1": f.fingerprint},
|
|
122
|
+
"properties": {
|
|
123
|
+
"title": f.title,
|
|
124
|
+
"severity": f.severity.value,
|
|
125
|
+
"confidence": f.confidence.value,
|
|
126
|
+
"standards": f.standards.model_dump(),
|
|
127
|
+
"evidenceIds": [e.id for e in f.evidence],
|
|
128
|
+
"details": _strip_nulls(f.details),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
if f.id in rule_index:
|
|
132
|
+
result["ruleIndex"] = rule_index[f.id]
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _strip_nulls(value: Any) -> Any:
|
|
137
|
+
"""SARIF property bags accept any JSON, but consumers choke on nulls; drop them."""
|
|
138
|
+
if isinstance(value, dict):
|
|
139
|
+
return {str(k): _strip_nulls(v) for k, v in value.items() if v is not None}
|
|
140
|
+
if isinstance(value, list):
|
|
141
|
+
return [_strip_nulls(v) for v in value if v is not None]
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def to_sarif(report: ScanReport, *, location_uri: str | None = None) -> dict[str, Any]:
|
|
146
|
+
rules = _rules()
|
|
147
|
+
rule_index = {r["id"]: i for i, r in enumerate(rules)}
|
|
148
|
+
uri = location_uri or default_location_uri(report)
|
|
149
|
+
invocation: dict[str, Any] = {
|
|
150
|
+
"executionSuccessful": True,
|
|
151
|
+
"startTimeUtc": report.started_at.isoformat(),
|
|
152
|
+
"toolExecutionNotifications": [
|
|
153
|
+
{
|
|
154
|
+
"level": "note",
|
|
155
|
+
"message": {
|
|
156
|
+
"text": f"{c.check_id}: {c.status.value}" + (f" ({c.reason})" if c.reason else "")
|
|
157
|
+
},
|
|
158
|
+
"associatedRule": {"id": c.check_id},
|
|
159
|
+
}
|
|
160
|
+
for c in report.checks
|
|
161
|
+
if c.status.value != "ran"
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
if report.finished_at is not None:
|
|
165
|
+
invocation["endTimeUtc"] = report.finished_at.isoformat()
|
|
166
|
+
run: dict[str, Any] = {
|
|
167
|
+
"tool": {
|
|
168
|
+
"driver": {
|
|
169
|
+
"name": "mcpprobe",
|
|
170
|
+
"version": __version__,
|
|
171
|
+
"semanticVersion": __version__,
|
|
172
|
+
"informationUri": INFORMATION_URI,
|
|
173
|
+
"rules": rules,
|
|
174
|
+
"properties": {"scanningPolicy": POLICY_URL},
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"automationDetails": {
|
|
178
|
+
"id": f"mcpprobe/{report.mode.value}/{report.started_at.strftime('%Y%m%dT%H%M%SZ')}"
|
|
179
|
+
},
|
|
180
|
+
"invocations": [invocation],
|
|
181
|
+
"results": [_result(f, rule_index, uri) for f in report.findings],
|
|
182
|
+
"properties": _strip_nulls(
|
|
183
|
+
{
|
|
184
|
+
"target": report.target,
|
|
185
|
+
"mode": report.mode.value,
|
|
186
|
+
"fingerprint": report.fingerprint.model_dump(mode="json") if report.fingerprint else None,
|
|
187
|
+
"counts": report.counts(),
|
|
188
|
+
"attestationOnly": [
|
|
189
|
+
{"id": a.id, "title": a.title, "status": a.status} for a in report.attestations
|
|
190
|
+
],
|
|
191
|
+
"notes": report.notes,
|
|
192
|
+
}
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
return {"$schema": SARIF_SCHEMA, "version": SARIF_VERSION, "runs": [run]}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def write_sarif(report: ScanReport, path: str | Path, *, location_uri: str | None = None) -> Path:
|
|
199
|
+
doc = to_sarif(report, location_uri=location_uri)
|
|
200
|
+
problems = validate_sarif(doc)
|
|
201
|
+
if problems: # pragma: no cover - guarded by tests
|
|
202
|
+
raise ValueError("generated SARIF is invalid: " + "; ".join(problems))
|
|
203
|
+
out = Path(path)
|
|
204
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
out.write_text(json.dumps(doc, indent=2), "utf-8")
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------- validation
|
|
210
|
+
def _nulls(value: Any, path: str, out: list[str]) -> None:
|
|
211
|
+
if value is None:
|
|
212
|
+
out.append(f"{path}: null is not allowed")
|
|
213
|
+
elif isinstance(value, dict):
|
|
214
|
+
for k, v in value.items():
|
|
215
|
+
_nulls(v, f"{path}.{k}", out)
|
|
216
|
+
elif isinstance(value, list):
|
|
217
|
+
for i, v in enumerate(value):
|
|
218
|
+
_nulls(v, f"{path}[{i}]", out)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _message_ok(msg: Any) -> bool:
|
|
222
|
+
return isinstance(msg, dict) and (isinstance(msg.get("text"), str) or isinstance(msg.get("id"), str))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def validate_sarif(doc: Any) -> list[str]:
|
|
226
|
+
"""Return a list of problems (empty when the log satisfies the checked SARIF 2.1.0 rules)."""
|
|
227
|
+
p: list[str] = []
|
|
228
|
+
if not isinstance(doc, dict):
|
|
229
|
+
return ["sarifLog must be an object"]
|
|
230
|
+
_nulls(doc, "$", p)
|
|
231
|
+
if doc.get("version") != SARIF_VERSION:
|
|
232
|
+
p.append("sarifLog.version must be '2.1.0'")
|
|
233
|
+
runs = doc.get("runs")
|
|
234
|
+
if not isinstance(runs, list):
|
|
235
|
+
return [*p, "sarifLog.runs must be an array"]
|
|
236
|
+
for ri, run in enumerate(runs):
|
|
237
|
+
where = f"runs[{ri}]"
|
|
238
|
+
if not isinstance(run, dict):
|
|
239
|
+
p.append(f"{where} must be an object")
|
|
240
|
+
continue
|
|
241
|
+
driver = (run.get("tool") or {}).get("driver") if isinstance(run.get("tool"), dict) else None
|
|
242
|
+
if not isinstance(driver, dict):
|
|
243
|
+
p.append(f"{where}.tool.driver is required")
|
|
244
|
+
driver = {}
|
|
245
|
+
if not isinstance(driver.get("name"), str) or not driver["name"]:
|
|
246
|
+
p.append(f"{where}.tool.driver.name is required")
|
|
247
|
+
rules = driver.get("rules", [])
|
|
248
|
+
if not isinstance(rules, list):
|
|
249
|
+
p.append(f"{where}.tool.driver.rules must be an array")
|
|
250
|
+
rules = []
|
|
251
|
+
ids = [r.get("id") for r in rules if isinstance(r, dict)]
|
|
252
|
+
if any(not isinstance(i, str) or not i for i in ids) or len(ids) != len(rules):
|
|
253
|
+
p.append(f"{where}: every rule needs a string id")
|
|
254
|
+
if len(set(ids)) != len(ids):
|
|
255
|
+
p.append(f"{where}: rule ids must be unique")
|
|
256
|
+
for i, r in enumerate(rules):
|
|
257
|
+
if not isinstance(r, dict):
|
|
258
|
+
continue
|
|
259
|
+
lvl = (r.get("defaultConfiguration") or {}).get("level")
|
|
260
|
+
if lvl is not None and lvl not in _LEVELS:
|
|
261
|
+
p.append(f"{where}.rules[{i}].defaultConfiguration.level invalid: {lvl!r}")
|
|
262
|
+
tags = (r.get("properties") or {}).get("tags")
|
|
263
|
+
if tags is not None and (not isinstance(tags, list) or len(set(tags)) != len(tags)):
|
|
264
|
+
p.append(f"{where}.rules[{i}].properties.tags must be unique strings")
|
|
265
|
+
for ii, inv in enumerate(run.get("invocations") or []):
|
|
266
|
+
if not isinstance(inv, dict) or not isinstance(inv.get("executionSuccessful"), bool):
|
|
267
|
+
p.append(f"{where}.invocations[{ii}].executionSuccessful (boolean) is required")
|
|
268
|
+
continue
|
|
269
|
+
for n, note in enumerate(inv.get("toolExecutionNotifications") or []):
|
|
270
|
+
if not _message_ok(note.get("message")):
|
|
271
|
+
p.append(f"{where}.invocations[{ii}].toolExecutionNotifications[{n}].message required")
|
|
272
|
+
results = run.get("results")
|
|
273
|
+
if results is None:
|
|
274
|
+
continue
|
|
275
|
+
if not isinstance(results, list):
|
|
276
|
+
p.append(f"{where}.results must be an array")
|
|
277
|
+
continue
|
|
278
|
+
for xi, res in enumerate(results):
|
|
279
|
+
rw = f"{where}.results[{xi}]"
|
|
280
|
+
if not isinstance(res, dict):
|
|
281
|
+
p.append(f"{rw} must be an object")
|
|
282
|
+
continue
|
|
283
|
+
if not _message_ok(res.get("message")):
|
|
284
|
+
p.append(f"{rw}.message with text or id is required")
|
|
285
|
+
rid = res.get("ruleId")
|
|
286
|
+
if rid is not None and not isinstance(rid, str):
|
|
287
|
+
p.append(f"{rw}.ruleId must be a string")
|
|
288
|
+
idx = res.get("ruleIndex")
|
|
289
|
+
if idx is not None:
|
|
290
|
+
if not isinstance(idx, int) or idx < -1 or idx >= len(rules):
|
|
291
|
+
p.append(f"{rw}.ruleIndex out of range")
|
|
292
|
+
elif idx >= 0 and rid is not None and ids[idx] != rid:
|
|
293
|
+
p.append(f"{rw}.ruleIndex does not match ruleId")
|
|
294
|
+
if rid is None and idx is None:
|
|
295
|
+
p.append(f"{rw} needs ruleId or ruleIndex")
|
|
296
|
+
if res.get("level", "warning") not in _LEVELS:
|
|
297
|
+
p.append(f"{rw}.level invalid")
|
|
298
|
+
if res.get("kind", "fail") not in _KINDS:
|
|
299
|
+
p.append(f"{rw}.kind invalid")
|
|
300
|
+
for li, loc in enumerate(res.get("locations") or []):
|
|
301
|
+
phys = loc.get("physicalLocation") if isinstance(loc, dict) else None
|
|
302
|
+
if phys is None:
|
|
303
|
+
continue
|
|
304
|
+
art = phys.get("artifactLocation")
|
|
305
|
+
if (
|
|
306
|
+
not (isinstance(art, dict) and isinstance(art.get("uri"), str) and art["uri"])
|
|
307
|
+
and "address" not in phys
|
|
308
|
+
):
|
|
309
|
+
p.append(f"{rw}.locations[{li}].physicalLocation needs artifactLocation.uri")
|
|
310
|
+
region = phys.get("region")
|
|
311
|
+
if region is not None and (
|
|
312
|
+
not isinstance(region.get("startLine", 1), int) or region.get("startLine", 1) < 1
|
|
313
|
+
):
|
|
314
|
+
p.append(f"{rw}.locations[{li}].region.startLine must be >= 1")
|
|
315
|
+
pf = res.get("partialFingerprints")
|
|
316
|
+
if pf is not None and not all(isinstance(v, str) for v in pf.values()):
|
|
317
|
+
p.append(f"{rw}.partialFingerprints values must be strings")
|
|
318
|
+
return p
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
__all__ = [
|
|
322
|
+
"LEVEL",
|
|
323
|
+
"SARIF_SCHEMA",
|
|
324
|
+
"SARIF_VERSION",
|
|
325
|
+
"SECURITY_SEVERITY",
|
|
326
|
+
"to_sarif",
|
|
327
|
+
"validate_sarif",
|
|
328
|
+
"write_sarif",
|
|
329
|
+
]
|