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/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
@@ -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
+ ]