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 ADDED
@@ -0,0 +1,21 @@
1
+ """mcpprobe: a security scanner for Model Context Protocol (MCP) servers.
2
+
3
+ The package is organised as follows:
4
+
5
+ - :mod:`mcpprobe.transport` -- a deliberately low-level MCP client (streamable
6
+ HTTP and stdio) that can send malformed or non-conformant requests and
7
+ records every exchange as evidence.
8
+ - :mod:`mcpprobe.checks` -- the plugin framework and the individual checks.
9
+ - :mod:`mcpprobe.authz` -- the hard authorisation gate for active mode.
10
+ - :mod:`mcpprobe.report` -- JSON, SARIF 2.1.0 and terminal reporters.
11
+ - :mod:`mcpprobe.scanner` -- the orchestrator tying everything together.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ POLICY_URL = "https://mcpprobe.tech/scanning-policy"
19
+ USER_AGENT = f"mcpprobe/{__version__} (+{POLICY_URL})"
20
+
21
+ __all__ = ["POLICY_URL", "USER_AGENT", "__version__"]
mcpprobe/action.py ADDED
@@ -0,0 +1,69 @@
1
+ """Entry point for the Docker container GitHub Action (``action.yml``).
2
+
3
+ GitHub passes action inputs as ``INPUT_<NAME>`` environment variables (name
4
+ upper-cased, hyphens kept). This module turns them into a CLI invocation,
5
+ always writes SARIF, records outputs in ``$GITHUB_OUTPUT`` and exits with the
6
+ scanner's exit code (0 clean, 1 findings at or above ``fail-on``, 2 error).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shlex
13
+ import sys
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ from mcpprobe.cli import EXIT_ERROR, main
18
+
19
+
20
+ def _input(env: Mapping[str, str], name: str, default: str = "") -> str:
21
+ return env.get(f"INPUT_{name.upper()}", default).strip()
22
+
23
+
24
+ def build_argv(env: Mapping[str, str]) -> list[str]:
25
+ """Translate action inputs into ``mcpprobe scan`` arguments."""
26
+ target = _input(env, "target")
27
+ if not target:
28
+ raise ValueError("input 'target' is required")
29
+ argv = ["scan", target]
30
+ argv += ["--fail-on", _input(env, "fail-on", "high") or "high"]
31
+ argv += ["--rate", _input(env, "rate", "5") or "5"]
32
+ argv += ["--sarif", _input(env, "sarif-output", "mcpprobe.sarif") or "mcpprobe.sarif"]
33
+ if json_out := _input(env, "json-output"):
34
+ argv += ["--json", json_out]
35
+ if loc := _input(env, "sarif-location"):
36
+ argv += ["--sarif-location", loc]
37
+ if baseline := _input(env, "baseline"):
38
+ argv += ["--baseline", baseline]
39
+ for line in _input(env, "headers").splitlines():
40
+ if line.strip():
41
+ argv += ["--header", line.strip()]
42
+ if extra := _input(env, "extra-args"):
43
+ argv += shlex.split(extra)
44
+ return argv
45
+
46
+
47
+ def run(env: Mapping[str, str] | None = None) -> int:
48
+ env = os.environ if env is None else env
49
+ try:
50
+ argv = build_argv(env)
51
+ except ValueError as exc:
52
+ print(f"::error::{exc}", file=sys.stderr)
53
+ return EXIT_ERROR
54
+ code = EXIT_ERROR
55
+ try:
56
+ main(argv)
57
+ except SystemExit as exc:
58
+ code = exc.code if isinstance(exc.code, int) else EXIT_ERROR
59
+ out = env.get("GITHUB_OUTPUT")
60
+ if out:
61
+ sarif = argv[argv.index("--sarif") + 1]
62
+ with Path(out).open("a", encoding="utf-8") as fh:
63
+ fh.write(f"sarif-file={sarif}\n")
64
+ fh.write(f"exit-code={code}\n")
65
+ return code
66
+
67
+
68
+ if __name__ == "__main__": # pragma: no cover
69
+ sys.exit(run())
mcpprobe/authz.py ADDED
@@ -0,0 +1,313 @@
1
+ """Authorisation gate for active mode.
2
+
3
+ Active checks call tools or send probe payloads, so they are allowed only when:
4
+
5
+ 1. the operator states who authorised the scan (``--authorised-by``) and the
6
+ agreed scope (``--scope``), **and**
7
+ 2. ownership verification passes:
8
+
9
+ * ``dns-txt``: a TXT record ``mcpprobe-verification=<token>`` exists at
10
+ ``_mcpprobe.<host>``. The token is generated locally with
11
+ ``mcpprobe token <host>`` and stored under the user's config directory, or
12
+ supplied with ``--token``.
13
+ * ``local``: only for stdio targets, which the operator runs on their own
14
+ machine.
15
+
16
+ Every active scan writes an authorisation record JSON next to the report. The
17
+ record is the evidence that the scan was authorised (who, what, which proof,
18
+ when, with which limits). Passive mode never consults this module, but the
19
+ ownership rule in README.md applies to every mode.
20
+
21
+ This is a gate in code, not policy: :func:`authorise` raises
22
+ :class:`AuthorisationError` and the scanner refuses to run active checks
23
+ without a verified record.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import ipaddress
31
+ import json
32
+ import os
33
+ import secrets
34
+ from collections.abc import Iterable
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from pydantic import BaseModel, Field
39
+
40
+ from mcpprobe import __version__
41
+ from mcpprobe.dnsutil import DnsLookupError, TxtLookup, default_txt_lookup, record_name
42
+ from mcpprobe.models import CheckMode, utcnow
43
+
44
+ TXT_PREFIX = "mcpprobe-verification="
45
+ VERIFY_METHODS = ("dns-txt", "local")
46
+
47
+
48
+ class AuthorisationError(Exception):
49
+ """Raised when active mode is requested without a valid authorisation."""
50
+
51
+
52
+ class VerificationResult(BaseModel):
53
+ method: str
54
+ verified: bool
55
+ detail: str
56
+ evidence: dict[str, Any] = Field(default_factory=dict)
57
+
58
+
59
+ class AuthorisationRecord(BaseModel):
60
+ """Written next to the report for every active scan."""
61
+
62
+ timestamp: str
63
+ scanner_version: str = __version__
64
+ target: str
65
+ transport: str
66
+ mode: CheckMode
67
+ authorised_by: str | None
68
+ scope: str | None
69
+ operator_reference: str | None = None
70
+ verification: VerificationResult
71
+ excluded_tools: list[str] = Field(default_factory=list)
72
+ rate_cap_rps: float
73
+ max_concurrent_tool_calls: int = 1
74
+ token_fingerprint: str | None = Field(
75
+ default=None, description="sha256 prefix of the verification token, never the token itself"
76
+ )
77
+
78
+ @property
79
+ def verified(self) -> bool:
80
+ return self.verification.verified
81
+
82
+
83
+ # ----------------------------------------------------------------------------- tokens
84
+ def generate_token() -> str:
85
+ """32 random bytes, base32 lower-case without padding (52 chars)."""
86
+ return base64.b32encode(secrets.token_bytes(32)).decode("ascii").rstrip("=").lower()
87
+
88
+
89
+ def token_store_path() -> Path:
90
+ override = os.environ.get("MCPPROBE_TOKEN_STORE")
91
+ if override:
92
+ return Path(override)
93
+ xdg = os.environ.get("XDG_CONFIG_HOME")
94
+ base = Path(xdg) if xdg else Path.home() / ".config"
95
+ return base / "mcpprobe" / "verification-tokens.json"
96
+
97
+
98
+ def _load_store(path: Path) -> dict[str, Any]:
99
+ if not path.exists():
100
+ return {}
101
+ try:
102
+ data = json.loads(path.read_text("utf-8"))
103
+ except (OSError, ValueError):
104
+ return {}
105
+ return data if isinstance(data, dict) else {}
106
+
107
+
108
+ def save_token(host: str, token: str, path: Path | None = None) -> Path:
109
+ path = path or token_store_path()
110
+ store = _load_store(path)
111
+ store[host.lower()] = {"token": token, "created_at": utcnow().isoformat()}
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ path.write_text(json.dumps(store, indent=2, sort_keys=True), "utf-8")
114
+ try:
115
+ path.chmod(0o600)
116
+ except OSError: # pragma: no cover - platform dependent
117
+ pass
118
+ return path
119
+
120
+
121
+ def load_token(host: str, path: Path | None = None) -> str | None:
122
+ entry = _load_store(path or token_store_path()).get(host.lower())
123
+ token = entry.get("token") if isinstance(entry, dict) else None
124
+ return token if isinstance(token, str) else None
125
+
126
+
127
+ def token_fingerprint(token: str) -> str:
128
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
129
+
130
+
131
+ def expected_txt_record(token: str) -> str:
132
+ return TXT_PREFIX + token
133
+
134
+
135
+ # ------------------------------------------------------------------------- verifiers
136
+ def verify_dns_txt(host: str, token: str, lookup: TxtLookup | None = None) -> VerificationResult:
137
+ """Check for ``mcpprobe-verification=<token>`` at ``_mcpprobe.<host>``."""
138
+ if not host:
139
+ return VerificationResult(method="dns-txt", verified=False, detail="target has no host name")
140
+ if _is_ip_literal(host):
141
+ return VerificationResult(
142
+ method="dns-txt",
143
+ verified=False,
144
+ detail=f"{host} is an IP address; DNS TXT verification needs a host name you control",
145
+ )
146
+ resolver = lookup or default_txt_lookup
147
+ expected = expected_txt_record(token)
148
+ name = record_name(host)
149
+ try:
150
+ records = [r.strip().strip('"') for r in resolver(name)]
151
+ except DnsLookupError as exc:
152
+ raise AuthorisationError(str(exc)) from exc
153
+ # Keep only our own records in the snapshot; other TXT data is not ours to store.
154
+ ours = [r for r in records if r.startswith(TXT_PREFIX)]
155
+ snapshot = {"name": name, "records": ours, "checked_at": utcnow().isoformat()}
156
+ if expected in records:
157
+ return VerificationResult(
158
+ method="dns-txt", verified=True, detail=f"TXT record found at {name}", evidence=snapshot
159
+ )
160
+ return VerificationResult(
161
+ method="dns-txt",
162
+ verified=False,
163
+ detail=f"no TXT record {TXT_PREFIX}<token> matching the saved token found at {name}",
164
+ evidence=snapshot,
165
+ )
166
+
167
+
168
+ def _is_ip_literal(host: str) -> bool:
169
+ try:
170
+ ipaddress.ip_address(host.strip("[]"))
171
+ except ValueError:
172
+ return False
173
+ return True
174
+
175
+
176
+ def verify_local(transport: str) -> VerificationResult:
177
+ if transport != "stdio":
178
+ return VerificationResult(
179
+ method="local",
180
+ verified=False,
181
+ detail="'local' verification is only valid for stdio targets the operator runs themselves",
182
+ )
183
+ return VerificationResult(
184
+ method="local",
185
+ verified=True,
186
+ detail="stdio package executed locally by the operator",
187
+ evidence={"checked_at": utcnow().isoformat()},
188
+ )
189
+
190
+
191
+ # ------------------------------------------------------------------------------ gate
192
+ def authorise(
193
+ *,
194
+ target: str,
195
+ transport: str,
196
+ host: str,
197
+ active: bool,
198
+ authorised_by: str | None,
199
+ scope: str | None,
200
+ verify_method: str | None,
201
+ token: str | None = None,
202
+ excluded_tools: Iterable[str] = (),
203
+ rate_cap_rps: float,
204
+ reference: str | None = None,
205
+ lookup: TxtLookup | None = None,
206
+ token_store: Path | None = None,
207
+ ) -> AuthorisationRecord:
208
+ """Build the authorisation record; raise :class:`AuthorisationError` if active mode is not allowed.
209
+
210
+ For passive mode the function still returns a record (unverified, for the
211
+ report), but never raises.
212
+ """
213
+ mode = CheckMode.ACTIVE if active else CheckMode.PASSIVE
214
+ fingerprint: str | None = None
215
+ if not active:
216
+ verification = VerificationResult(
217
+ method="none", verified=False, detail="passive mode; no verification"
218
+ )
219
+ return _record(
220
+ target,
221
+ transport,
222
+ mode,
223
+ authorised_by,
224
+ scope,
225
+ reference,
226
+ verification,
227
+ excluded_tools,
228
+ rate_cap_rps,
229
+ None,
230
+ )
231
+
232
+ missing = [name for name, value in (("--authorised-by", authorised_by), ("--scope", scope)) if not value]
233
+ if missing:
234
+ raise AuthorisationError("active mode requires " + " and ".join(missing))
235
+ if verify_method not in VERIFY_METHODS:
236
+ raise AuthorisationError(
237
+ f"active mode requires --verify {{{'|'.join(VERIFY_METHODS)}}} (got {verify_method!r})"
238
+ )
239
+ if verify_method == "local":
240
+ verification = verify_local(transport)
241
+ else:
242
+ token = token or load_token(host, token_store)
243
+ if not token:
244
+ raise AuthorisationError(
245
+ f"no verification token for {host}; run 'mcpprobe token {host}' and publish the TXT record, "
246
+ "or pass --token"
247
+ )
248
+ fingerprint = token_fingerprint(token)
249
+ verification = verify_dns_txt(host, token, lookup)
250
+ if not verification.verified:
251
+ raise AuthorisationError(f"ownership verification failed: {verification.detail}")
252
+ return _record(
253
+ target,
254
+ transport,
255
+ mode,
256
+ authorised_by,
257
+ scope,
258
+ reference,
259
+ verification,
260
+ excluded_tools,
261
+ rate_cap_rps,
262
+ fingerprint,
263
+ )
264
+
265
+
266
+ def _record(
267
+ target: str,
268
+ transport: str,
269
+ mode: CheckMode,
270
+ authorised_by: str | None,
271
+ scope: str | None,
272
+ reference: str | None,
273
+ verification: VerificationResult,
274
+ excluded_tools: Iterable[str],
275
+ rate_cap_rps: float,
276
+ fingerprint: str | None,
277
+ ) -> AuthorisationRecord:
278
+ return AuthorisationRecord(
279
+ timestamp=utcnow().isoformat(),
280
+ target=target,
281
+ transport=transport,
282
+ mode=mode,
283
+ authorised_by=authorised_by,
284
+ scope=scope,
285
+ operator_reference=reference,
286
+ verification=verification,
287
+ excluded_tools=sorted(set(excluded_tools)),
288
+ rate_cap_rps=rate_cap_rps,
289
+ token_fingerprint=fingerprint,
290
+ )
291
+
292
+
293
+ def record_path_for(report_path: str | os.PathLike[str] | None) -> Path:
294
+ """``<report>.authorisation.json`` next to the report, or a timestamped file in cwd."""
295
+ if report_path:
296
+ p = Path(report_path)
297
+ return p.with_name(p.stem + ".authorisation.json")
298
+ stamp = utcnow().strftime("%Y%m%dT%H%M%SZ")
299
+ return Path(f"mcpprobe-authorisation-{stamp}.json")
300
+
301
+
302
+ def write_record(record: AuthorisationRecord, report_path: str | os.PathLike[str] | None) -> Path:
303
+ path = record_path_for(report_path)
304
+ path.parent.mkdir(parents=True, exist_ok=True)
305
+ path.write_text(record.model_dump_json(indent=2), "utf-8")
306
+ return path
307
+
308
+
309
+ def require_active_authorisation(record: AuthorisationRecord | None) -> AuthorisationRecord:
310
+ """Second gate used by the orchestrator right before running active checks."""
311
+ if record is None or record.mode != CheckMode.ACTIVE or not record.verified:
312
+ raise AuthorisationError("active checks refused: no verified authorisation record")
313
+ return record
mcpprobe/catalogue.py ADDED
@@ -0,0 +1,125 @@
1
+ """Generate the public checks catalogue (CHECKS.md) from check metadata.
2
+
3
+ ``mcpprobe checks --format markdown`` prints the same text, and a test asserts
4
+ that the committed CHECKS.md matches, so the documentation cannot drift from
5
+ the code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from mcpprobe import __version__
11
+ from mcpprobe.checks import ALL_CHECKS, Check
12
+ from mcpprobe.governance import GOVERNANCE_ITEMS
13
+ from mcpprobe.models import CheckMode, Standards
14
+
15
+
16
+ def _std(std: Standards) -> str:
17
+ parts = [f"{m} (beta)" for m in std.mcp] + list(std.asi) + list(std.llm_2026) + list(std.llm_2025)
18
+ return ", ".join(parts) if parts else "MCP spec conformance / hygiene (no OWASP item)"
19
+
20
+
21
+ def _transports(cls: type[Check]) -> str:
22
+ return ", ".join(sorted(cls.applies_to))
23
+
24
+
25
+ def _section(cls: type[Check]) -> list[str]:
26
+ status = "implemented" if cls.mode == CheckMode.PASSIVE else "stub (gated, not implemented)"
27
+ return [
28
+ f"### {cls.id} - {cls.title}",
29
+ "",
30
+ "| | |",
31
+ "|---|---|",
32
+ f"| Mode | {cls.mode.value} ({status}) |",
33
+ f"| Default severity | {cls.severity.value} |",
34
+ f"| Transports | {_transports(cls)} |",
35
+ f"| Standards | {_std(cls.standards)} |",
36
+ f"| Evidence | {cls.evidence_type or '-'} |",
37
+ "",
38
+ cls.description,
39
+ "",
40
+ f"**False positives.** {cls.false_positive_notes or 'None known.'}",
41
+ "",
42
+ f"**Fix.** {cls.fix or '-'}",
43
+ "",
44
+ ]
45
+
46
+
47
+ def checks_markdown() -> str:
48
+ lines: list[str] = [
49
+ "# mcpprobe checks catalogue",
50
+ "",
51
+ f"Generated from the check metadata of mcpprobe {__version__} "
52
+ "(`mcpprobe checks --format markdown`). Do not edit by hand.",
53
+ "",
54
+ "Standards: OWASP MCP Top 10 (IDs `MCP0x:2025`, still a beta list, so shown with `(beta)`), "
55
+ "OWASP Top 10 for Agentic Applications (`ASI0x`), OWASP Top 10 for LLM Applications 2026 "
56
+ "(`LLM0x:2026`, primary) and 2025 (`LLM0x:2025`, kept for one release cycle). Severities are "
57
+ "defaults; a check can raise or lower the severity of an individual finding (for example "
58
+ "loopback-only servers are reported one level lower), and heuristic checks never rate a "
59
+ "finding Critical on their own.",
60
+ "",
61
+ "Passive checks send only what a normal MCP client would send plus a few benign malformed "
62
+ "requests. They never call a tool. Active checks are listed so coverage is reported honestly; "
63
+ "in this release they are stubs that run only behind the authorisation gate and report "
64
+ "`not_implemented`.",
65
+ "",
66
+ "## Summary",
67
+ "",
68
+ "| ID | Mode | Default severity | Title | Standards |",
69
+ "|---|---|---|---|---|",
70
+ ]
71
+ for cls in ALL_CHECKS:
72
+ lines.append(
73
+ f"| [{cls.id}](#{cls.id.lower()}---{_anchor(cls.title)}) | {cls.mode.value} | "
74
+ f"{cls.severity.value} | {cls.title} | {_std(cls.standards)} |"
75
+ )
76
+ for item in GOVERNANCE_ITEMS:
77
+ lines.append(f"| {item.id} | attestation | - | {item.title} | {_std(item.standards)} |")
78
+ lines += ["", "## Passive checks", ""]
79
+ for cls in ALL_CHECKS:
80
+ if cls.mode == CheckMode.PASSIVE:
81
+ lines += _section(cls)
82
+ lines += [
83
+ "## Active checks (stubs)",
84
+ "",
85
+ "Rules of engagement for when these are implemented: non-destructive, non-persistent "
86
+ "canaries only; tools excluded with `--exclude-tool` or annotated `destructiveHint: true` are "
87
+ "never called; at most one concurrent tool call; every payload is recorded as evidence; the "
88
+ "scan stops on the first sign of an unintended side effect.",
89
+ "",
90
+ ]
91
+ for cls in ALL_CHECKS:
92
+ if cls.mode == CheckMode.ACTIVE:
93
+ lines += _section(cls)
94
+ lines += [
95
+ "## Governance items (attestation only)",
96
+ "",
97
+ "These cannot be assessed by an automated black-box scan. Every report lists them as "
98
+ "`attestation-only` with the checklist below; mcpprobe never marks them passed or failed.",
99
+ "",
100
+ ]
101
+ for item in GOVERNANCE_ITEMS:
102
+ lines += [
103
+ f"### {item.id} - {item.title}",
104
+ "",
105
+ f"Standards: {_std(item.standards)}",
106
+ "",
107
+ item.reason,
108
+ "",
109
+ ]
110
+ lines += [f"- [ ] {c}" for c in item.checklist]
111
+ lines.append("")
112
+ return "\n".join(lines).rstrip() + "\n"
113
+
114
+
115
+ def _anchor(title: str) -> str:
116
+ out = []
117
+ for ch in title.lower():
118
+ if ch.isalnum() or ch in "-_":
119
+ out.append(ch)
120
+ elif ch == " ":
121
+ out.append("-")
122
+ return "".join(out)
123
+
124
+
125
+ __all__ = ["checks_markdown"]
@@ -0,0 +1,100 @@
1
+ """Check registry.
2
+
3
+ ``ALL_CHECKS`` lists every check class in execution order. Passive checks run
4
+ by default; active ones are stubs gated behind the authorisation record.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+
11
+ from mcpprobe.checks.active_stubs import ACTIVE_STUBS
12
+ from mcpprobe.checks.auth import (
13
+ Auth01NoAuthentication,
14
+ Auth02MissingProtectedResourceMetadata,
15
+ Auth03AuthorizationServerMetadata,
16
+ Auth04TokenAudienceAndPassthrough,
17
+ )
18
+ from mcpprobe.checks.base import (
19
+ ActiveCheck,
20
+ Check,
21
+ JudgeFn,
22
+ JudgeVerdict,
23
+ ScanContext,
24
+ ScanOptions,
25
+ )
26
+ from mcpprobe.checks.baseline import Meta02RugPullBaseline
27
+ from mcpprobe.checks.metadata import (
28
+ Meta03DangerousCapabilitySurface,
29
+ Meta04SchemaHygiene,
30
+ Meta05SecretsInMetadata,
31
+ )
32
+ from mcpprobe.checks.poisoning import Meta01ToolDescriptionPoisoning
33
+ from mcpprobe.checks.robustness import Dos01UnboundedConsumption, Err01VerboseErrors
34
+ from mcpprobe.checks.transport_conformance import (
35
+ Trans01OriginValidation,
36
+ Trans02ProtocolVersionHandling,
37
+ Trans03SessionIdQuality,
38
+ Trans04DeprecatedTransport,
39
+ Trans05TlsAndHeaders,
40
+ )
41
+
42
+ PASSIVE_CHECKS: list[type[Check]] = [
43
+ Auth01NoAuthentication,
44
+ Auth02MissingProtectedResourceMetadata,
45
+ Auth03AuthorizationServerMetadata,
46
+ Auth04TokenAudienceAndPassthrough,
47
+ Trans01OriginValidation,
48
+ Trans02ProtocolVersionHandling,
49
+ Trans03SessionIdQuality,
50
+ Trans04DeprecatedTransport,
51
+ Trans05TlsAndHeaders,
52
+ Meta01ToolDescriptionPoisoning,
53
+ Meta02RugPullBaseline,
54
+ Meta03DangerousCapabilitySurface,
55
+ Meta04SchemaHygiene,
56
+ Meta05SecretsInMetadata,
57
+ Err01VerboseErrors,
58
+ Dos01UnboundedConsumption,
59
+ ]
60
+
61
+ ALL_CHECKS: list[type[Check]] = [*PASSIVE_CHECKS, *ACTIVE_STUBS]
62
+
63
+ _BY_ID: dict[str, type[Check]] = {c.id: c for c in ALL_CHECKS}
64
+
65
+
66
+ def get_check(check_id: str) -> type[Check]:
67
+ try:
68
+ return _BY_ID[check_id.upper()]
69
+ except KeyError as exc:
70
+ raise KeyError(f"unknown check id: {check_id}") from exc
71
+
72
+
73
+ def select_checks(only: Iterable[str] | None = None, skip: Iterable[str] | None = None) -> list[Check]:
74
+ """Instantiate checks, honouring ``--only`` / ``--skip`` lists."""
75
+ only_set = {s.upper() for s in only} if only else None
76
+ skip_set = {s.upper() for s in skip} if skip else set()
77
+ for cid in (only_set or set()) | skip_set:
78
+ get_check(cid) # raise on typos
79
+ out: list[Check] = []
80
+ for cls in ALL_CHECKS:
81
+ if only_set is not None and cls.id not in only_set:
82
+ continue
83
+ if cls.id in skip_set:
84
+ continue
85
+ out.append(cls())
86
+ return out
87
+
88
+
89
+ __all__ = [
90
+ "ALL_CHECKS",
91
+ "PASSIVE_CHECKS",
92
+ "ActiveCheck",
93
+ "Check",
94
+ "JudgeFn",
95
+ "JudgeVerdict",
96
+ "ScanContext",
97
+ "ScanOptions",
98
+ "get_check",
99
+ "select_checks",
100
+ ]