attackmap 0.6.4__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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/cli.py ADDED
@@ -0,0 +1,1474 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections import Counter
6
+ import shlex
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from .analyzer import summarize_architecture, summarize_attack_surface
13
+ from .defensive_review import render_defensive_review
14
+ from .analyzers import (
15
+ TRUSTED_ONLY_ENV,
16
+ MissingAnalyzersError,
17
+ analyze_repository,
18
+ analyzer_install_command,
19
+ get_available_modules,
20
+ get_available_repository_modules,
21
+ get_analyzer_metadata,
22
+ install_analyzer_module,
23
+ resolve_run_analyzers,
24
+ select_requested_analyzers,
25
+ )
26
+ from .cve import query_vulnerabilities
27
+ from .anomalies import route_auth_signals
28
+ from .contracts import link_contracts
29
+ from .crossrepo import (
30
+ find_cross_boundary_flows,
31
+ find_cross_repo_anomalies,
32
+ find_trust_gaps,
33
+ )
34
+ from .fleet import (
35
+ FleetRepoResult,
36
+ FleetScan,
37
+ fleet_repo_ids,
38
+ fleet_summary_json,
39
+ render_fleet_graph_mermaid,
40
+ render_fleet_summary,
41
+ )
42
+ from .diff import (
43
+ FindingSnapshot,
44
+ diff_findings,
45
+ load_baseline,
46
+ render_diff_markdown,
47
+ )
48
+ from .graph import build_graph
49
+ from .hunt_harness import run_majority_verify
50
+ from .llm_review import LlmReviewError, generate_llm_review
51
+ from .review_prompts import HUNT_LENSES
52
+ from .triage import render_triage_fallback
53
+ from .progress import PROGRESS_PROTOCOL_VERSION, create_progress
54
+ from .recon_to_analysis import translate_recon
55
+ from . import __version__
56
+ from .md import sanitize_llm_markdown
57
+ from .safe_fs import UnsafePathError, ensure_output_dir, safe_write_text
58
+ from .report import OUTPUT_FORMATS, render_console_summary, render_pr_comment, write_reports
59
+ from .suggest import detect_ecosystems
60
+ from .gitref import GitRefError
61
+ from .suppress import apply_suppressions, collect_suppressions
62
+ from .gitref import untrusted_output_markers
63
+ from .safe_fs import set_untrusted_output_markers
64
+ from .scanner import EXCLUDED_DIR_LIMITATION
65
+
66
+ app = typer.Typer(help="AttackMap: understand your system and map your attack surface.")
67
+
68
+
69
+ def _select_modules(module: list[str], install_missing: bool) -> list:
70
+ """Resolve `-m` names (#237). Only official, lock-pinned plugins can be
71
+ installed by name, and only with --install-missing or an interactive
72
+ confirmation; otherwise the exact pinned pip command is printed."""
73
+ try:
74
+ return select_requested_analyzers(module, auto_install=False)
75
+ except MissingAnalyzersError as exc:
76
+ commands = [shlex.join(analyzer_install_command(name)) for name in exc.missing]
77
+ interactive = sys.stdin.isatty() and sys.stderr.isatty()
78
+ if install_missing or (
79
+ interactive
80
+ and typer.confirm(
81
+ "Install missing official analyzer(s) at their pinned commits?\n "
82
+ + "\n ".join(commands),
83
+ default=False,
84
+ err=True,
85
+ )
86
+ ):
87
+ try:
88
+ return select_requested_analyzers(module, auto_install=True)
89
+ except ValueError as install_exc:
90
+ raise typer.BadParameter(str(install_exc)) from install_exc
91
+ raise typer.BadParameter(
92
+ f"{exc} Install with:\n " + "\n ".join(commands) + "\nor re-run with --install-missing."
93
+ ) from exc
94
+ except ValueError as exc:
95
+ raise typer.BadParameter(str(exc)) from exc
96
+
97
+
98
+ def _warn_if_nothing_scanned(scan) -> None: # type: ignore[no-untyped-def]
99
+ """A run that scanned nothing is almost always a setup problem — say so
100
+ instead of reporting a clean "limited attack surface" (#215)."""
101
+ if scan.files_scanned:
102
+ return
103
+ root = Path(scan.root)
104
+ try:
105
+ has_files = any(p.is_file() for p in root.iterdir())
106
+ except OSError:
107
+ has_files = False
108
+ if has_files or any(root.glob("*/*")):
109
+ typer.echo(
110
+ "Warning: no source files were scanned. Check the path, and whether every file "
111
+ "is under a skipped directory (node_modules, dist, build, out, target, …) or in an "
112
+ "unsupported language.",
113
+ err=True,
114
+ )
115
+
116
+
117
+ def _distrust_pr_markers(repo_path: Path, ref: str | None, output_dir: Path) -> None:
118
+ """Under --suppress-from-ref, an `.attackmap-output` marker that is not at
119
+ the ref was added by the PR: it must not exclude its directory from the
120
+ scan (GHSA-j544-8x8g-c3mw). The run's own output directory is trusted."""
121
+ if ref is None:
122
+ set_untrusted_output_markers(())
123
+ return
124
+ untrusted = untrusted_output_markers(repo_path, ref, keep_under=[output_dir])
125
+ for rel in untrusted:
126
+ typer.echo(
127
+ f"Scan warning: output marker {rel.as_posix()} was added since {ref}; "
128
+ "its directory is scanned anyway",
129
+ err=True,
130
+ )
131
+ set_untrusted_output_markers(repo_path / rel for rel in untrusted)
132
+
133
+
134
+ def _excluded_dirs(scan) -> list[str]: # noqa: ANN001
135
+ return [
136
+ note[len(EXCLUDED_DIR_LIMITATION):]
137
+ for note in getattr(scan, "limitations", [])
138
+ if note.startswith(EXCLUDED_DIR_LIMITATION)
139
+ ]
140
+
141
+
142
+ def _ensure_output_dir(path: Path, *, mark: bool = False) -> Path:
143
+ try:
144
+ return ensure_output_dir(path, mark=mark)
145
+ except UnsafePathError as exc:
146
+ typer.echo(f"Error: {exc}", err=True)
147
+ raise typer.Exit(2) from exc
148
+
149
+
150
+ def _write_text(path: Path, text: str) -> None:
151
+ """Write a CLI artifact without following symlinks (#228)."""
152
+ path = Path(path)
153
+ try:
154
+ safe_write_text(ensure_output_dir(path.parent), path, text)
155
+ except UnsafePathError as exc:
156
+ typer.echo(f"Error: {exc}", err=True)
157
+ raise typer.Exit(2) from exc
158
+
159
+
160
+ def _version_callback(value: bool) -> None:
161
+ if value:
162
+ typer.echo(f"attackmap {__version__}")
163
+ raise typer.Exit()
164
+
165
+
166
+ @app.callback()
167
+ def main(
168
+ version: bool = typer.Option(
169
+ False,
170
+ "--version",
171
+ callback=_version_callback,
172
+ is_eager=True,
173
+ help="Show the AttackMap version and exit.",
174
+ ),
175
+ ) -> None:
176
+ """AttackMap: understand your system and map your attack surface."""
177
+
178
+ # Prepended to vulnerability-hypotheses.md (#80) so the framing is unmissable
179
+ # even if a reader skips straight to the file: these are leads, not detections.
180
+ HUNT_BANNER = (
181
+ "> ⚠️ **These are HYPOTHESES to confirm, not confirmed vulnerabilities.**\n"
182
+ "> Generated by an LLM reasoning over AttackMap's static evidence as a red-team\n"
183
+ "> analyst. Each hypothesis cites the evidence it rests on and lists what a human\n"
184
+ "> must verify. Treat every item as a lead requiring code review before action —\n"
185
+ "> no CVE assignment, no exploitation instructions.\n\n"
186
+ )
187
+
188
+ # Prepended to remediation.md (#106): suggestions are review-first, not
189
+ # auto-applied.
190
+ REMEDIATION_BANNER = (
191
+ "> 🛠️ **Suggested fixes to review, not auto-applied.**\n"
192
+ "> LLM-proposed remediations grounded in AttackMap's static evidence. Each is\n"
193
+ "> the smallest change to remove a weakness class; review the diff, confirm the\n"
194
+ "> intent (e.g. whether a route should require auth), and run the verification\n"
195
+ "> step before applying. No exploit code.\n\n"
196
+ )
197
+
198
+ # Prepended to triage.md (#145): a prioritized view of the EXISTING findings,
199
+ # not new discoveries.
200
+ TRIAGE_BANNER = (
201
+ "> 🗂️ **Prioritized shortlist of existing findings — not new discoveries.**\n"
202
+ "> Triage clusters, de-duplicates, and ranks the heuristic findings AttackMap\n"
203
+ "> already produced so you can work them top-down. Each item cites a real\n"
204
+ "> finding id; no new findings are invented here.\n\n"
205
+ )
206
+
207
+
208
+ PROGRESS_FORMATS = ("auto", "tty", "json", "none")
209
+ LLM_PROVIDERS = ("claude", "openai")
210
+ LLM_SPEEDS = ("standard", "fast")
211
+ LLM_EFFORTS = ("low", "medium", "high", "xhigh", "max")
212
+ LLM_BACKENDS = ("auto", "api", "cli")
213
+
214
+
215
+ def _given(ctx: typer.Context, name: str) -> bool:
216
+ """True when the user passed the option (not just its default)."""
217
+ source = ctx.get_parameter_source(name)
218
+ return source is not None and source.name not in {"DEFAULT", "DEFAULT_MAP"}
219
+
220
+
221
+ def _validate_analyze_options(
222
+ ctx: typer.Context,
223
+ *,
224
+ progress_format: str,
225
+ output_format: str,
226
+ llm_provider: str,
227
+ llm_speed: str,
228
+ llm_effort: str | None,
229
+ llm_backend: str,
230
+ hunt: bool,
231
+ verify: bool,
232
+ verify_votes: int,
233
+ hunt_lenses: int,
234
+ hunt_rounds: int,
235
+ hunt_budget: int,
236
+ baseline: str | None,
237
+ diff_output: str | None,
238
+ secrets_history: int = 0,
239
+ ) -> None:
240
+ """Reject bad values and orphaned flags before any scanning starts (#229).
241
+
242
+ A typo in an LLM option used to surface only after the full scan, CVE
243
+ lookup and report writing; flags whose parent option was missing were
244
+ silently ignored with exit 0.
245
+ """
246
+
247
+ def _choice(flag: str, value: str | None, allowed: tuple[str, ...]) -> None:
248
+ if value is not None and value not in allowed:
249
+ raise typer.BadParameter(f"{flag} must be one of: {', '.join(allowed)} (got {value!r}).")
250
+
251
+ _choice("--progress-format", progress_format, PROGRESS_FORMATS)
252
+ _choice("--format", output_format, tuple(OUTPUT_FORMATS))
253
+ _choice("--llm-provider", llm_provider, LLM_PROVIDERS)
254
+ _choice("--llm-speed", llm_speed, LLM_SPEEDS)
255
+ _choice("--llm-effort", llm_effort, LLM_EFFORTS)
256
+ _choice("--llm-backend", llm_backend, LLM_BACKENDS)
257
+
258
+ hunt_only = ("verify", "verify_votes", "hunt_lenses", "hunt_rounds", "hunt_budget")
259
+ orphaned = [f"--{name.replace('_', '-')}" for name in hunt_only if _given(ctx, name)]
260
+ if orphaned and not hunt:
261
+ verb = "only applies" if len(orphaned) == 1 else "only apply"
262
+ raise typer.BadParameter(f"{', '.join(orphaned)} {verb} with --hunt.")
263
+ if _given(ctx, "diff_output") and diff_output is not None and baseline is None:
264
+ raise typer.BadParameter("--diff-output requires --baseline to be set.")
265
+
266
+ if not 0 <= secrets_history <= 5000:
267
+ raise typer.BadParameter(f"--secrets-history must be between 0 (off) and 5000 commits (got {secrets_history}).")
268
+ if verify_votes < 1:
269
+ raise typer.BadParameter(f"--verify-votes must be at least 1 (got {verify_votes}).")
270
+ if not 1 <= hunt_lenses <= len(HUNT_LENSES):
271
+ raise typer.BadParameter(f"--hunt-lenses must be between 1 and {len(HUNT_LENSES)} (got {hunt_lenses}).")
272
+ if hunt_rounds < 1:
273
+ raise typer.BadParameter(f"--hunt-rounds must be at least 1 (got {hunt_rounds}).")
274
+ if hunt_budget < 0:
275
+ raise typer.BadParameter(f"--hunt-budget must be 0 (no cap) or more (got {hunt_budget}).")
276
+
277
+
278
+ def _echo_opt_in_hint(names: list[str]) -> None:
279
+ """Opt-in analyzers (enabled_by_default=False) that matched but didn't run
280
+ (#221), so their absence is visible rather than silent."""
281
+ if names:
282
+ typer.echo(
283
+ f"Opt-in analyzers match this repo but were not run: {', '.join(names)}. "
284
+ f"Enable with {' '.join(f'-m {n}' for n in names)}.",
285
+ err=True,
286
+ )
287
+
288
+
289
+ def _run_fleet(
290
+ repo_paths: list[Path],
291
+ *,
292
+ output: str,
293
+ module: list[str] | None,
294
+ recall: bool,
295
+ cve: bool,
296
+ no_suppress: bool,
297
+ suppress_file: str | None,
298
+ progress_format: str,
299
+ no_progress: bool,
300
+ fleet_incompatible: dict[str, bool],
301
+ output_format: str = "all",
302
+ install_missing: bool = False,
303
+ suppress_from_ref: str | None = None,
304
+ strict_analyzers: bool = False,
305
+ ) -> None:
306
+ """Multi-repo fleet scan (#146a). Scans each repo independently — reusing the
307
+ same building blocks a single-repo run uses — writes per-repo reports into
308
+ ``output/<repo_id>/``, and assembles a fleet summary. No cross-repo detection
309
+ yet (that is #146b–#146d); this is the foundation."""
310
+ active_flags = [flag for flag, on in fleet_incompatible.items() if on]
311
+ if active_flags:
312
+ raise typer.BadParameter(
313
+ f"{', '.join(sorted(active_flags))} not yet supported in multi-repo mode — "
314
+ "run one repository at a time for these (fleet-aware wiring lands in #146b+)."
315
+ )
316
+
317
+ selected_analyzers = None
318
+ if module:
319
+ selected_analyzers = _select_modules(module, install_missing)
320
+
321
+ output_root = Path(output)
322
+ repo_ids = fleet_repo_ids(repo_paths)
323
+ fleet = FleetScan()
324
+
325
+ for repo_id, repo_path in zip(repo_ids, repo_paths):
326
+ typer.echo("")
327
+ typer.echo(f"── {repo_id} ({repo_path}) ──")
328
+ try:
329
+ opt_in_matches: list[str] = []
330
+ active_analyzers = resolve_run_analyzers(
331
+ repo_path, analyzers=selected_analyzers, opt_in_matches=opt_in_matches
332
+ )
333
+ _echo_opt_in_hint(opt_in_matches)
334
+ scan_progress = create_progress(progress_format, no_progress=no_progress)
335
+ _distrust_pr_markers(repo_path, suppress_from_ref, output_root)
336
+ scan = analyze_repository(
337
+ repo_path,
338
+ analyzers=active_analyzers,
339
+ progress=scan_progress,
340
+ recall=recall,
341
+ strict=strict_analyzers,
342
+ )
343
+ if cve and scan.dependencies:
344
+ try:
345
+ vulns, _cve_summary = query_vulnerabilities(
346
+ scan.dependencies, progress=scan_progress
347
+ )
348
+ finally:
349
+ scan_progress.done()
350
+ scan.vulnerabilities = vulns
351
+
352
+ graph = build_graph(scan)
353
+ analysis = translate_recon(scan)
354
+ attack_surfaces = analysis.attack_surfaces
355
+ findings = analysis.findings
356
+ attack_paths = analysis.attack_paths
357
+ architecture_md = summarize_architecture(scan, graph)
358
+ attack_surface_md = summarize_attack_surface(scan, attack_surfaces)
359
+
360
+ suppressed_findings: list = []
361
+ if not no_suppress:
362
+ explicit = Path(suppress_file) if suppress_file else None
363
+ if explicit is not None and not explicit.exists():
364
+ raise typer.BadParameter(f"Suppress file not found: {explicit}")
365
+ try:
366
+ suppset, sup_warnings = collect_suppressions(
367
+ repo_path, findings, explicit_file=explicit, trusted_ref=suppress_from_ref
368
+ )
369
+ except GitRefError as exc:
370
+ raise typer.BadParameter(str(exc)) from exc
371
+ for warning in sup_warnings:
372
+ typer.echo(f"Suppression warning: {warning}", err=True)
373
+ outcome = apply_suppressions(findings, suppset)
374
+ for notice in outcome.notices:
375
+ typer.echo(f"Suppression warning: {notice}", err=True)
376
+ findings = outcome.active
377
+ suppressed_findings = outcome.suppressed
378
+
379
+ defensive_review_md = render_defensive_review(scan, attack_surfaces, findings, attack_paths)
380
+ repo_out = output_root / repo_id
381
+ try:
382
+ write_reports(
383
+ repo_out,
384
+ scan,
385
+ architecture_md,
386
+ attack_surface_md,
387
+ defensive_review_md,
388
+ attack_surfaces,
389
+ findings,
390
+ attack_paths,
391
+ analyzer_metadata=[
392
+ {
393
+ "name": metadata.name,
394
+ "description": metadata.description,
395
+ "scope": metadata.scope,
396
+ "ecosystems": list(metadata.ecosystems),
397
+ }
398
+ for metadata in (get_analyzer_metadata(a) for a in active_analyzers)
399
+ ],
400
+ suppressed=suppressed_findings,
401
+ output_format=output_format,
402
+ )
403
+ except UnsafePathError as exc:
404
+ typer.echo(f"Error: {exc}", err=True)
405
+ raise typer.Exit(2) from exc
406
+ typer.echo(render_console_summary(scan, findings, attack_paths))
407
+ _warn_if_nothing_scanned(scan)
408
+ fleet.results.append(
409
+ FleetRepoResult(
410
+ repo_id=repo_id,
411
+ root=str(repo_path),
412
+ report_dir=str(repo_out.resolve()),
413
+ scan=scan,
414
+ findings=findings,
415
+ attack_paths=attack_paths,
416
+ suppressed_count=len(suppressed_findings),
417
+ )
418
+ )
419
+ except (typer.Exit, typer.Abort, typer.BadParameter):
420
+ raise # usage / configuration errors stop the whole run
421
+ except Exception as exc: # noqa: BLE001 — one bad repo must not sink the fleet (#217)
422
+ message = f"{type(exc).__name__}: {exc}"
423
+ typer.echo(f"Error: {repo_id} failed and was skipped — {message}", err=True)
424
+ fleet.failed.append({"repo_id": repo_id, "root": str(repo_path), "error": message})
425
+
426
+ # Cross-repo contract linking (#146b): match one repo's outbound calls to
427
+ # another repo's routes, over the assembled fleet.
428
+ repo_scans = [(r.repo_id, r.scan) for r in fleet.results]
429
+ fleet.links = link_contracts(repo_scans)
430
+ # Cross-boundary trust flows (#146c): confused-deputy leads over the links.
431
+ fleet.cross_boundary = find_cross_boundary_flows(fleet.links, repo_scans)
432
+ # Trust-assumption gaps + cross-repo anomalies (#146d / #149b). Per-route auth
433
+ # signals are resolved per repo (each with its own root).
434
+ auth_by_repo = {r.repo_id: route_auth_signals(r.scan, r.root) for r in fleet.results}
435
+ fleet.trust_gaps = find_trust_gaps(fleet.links, repo_scans, auth_by_repo)
436
+ fleet.cross_repo_anomalies = find_cross_repo_anomalies(repo_scans, auth_by_repo)
437
+
438
+ _ensure_output_dir(output_root, mark=True)
439
+ emit_markdown = output_format in {"all", "markdown"}
440
+ if emit_markdown:
441
+ _write_text(output_root / "fleet-summary.md", render_fleet_summary(fleet) + "\n")
442
+ _write_text(output_root / "fleet-graph.md", render_fleet_graph_mermaid(fleet) + "\n")
443
+ if output_format in {"all", "json"}:
444
+ _write_text(
445
+ output_root / "fleet-summary.json",
446
+ json.dumps(fleet_summary_json(fleet), indent=2) + "\n",
447
+ )
448
+ summary_name = "fleet-summary.md" if emit_markdown else "fleet-summary.json"
449
+ typer.echo("")
450
+ typer.echo(
451
+ f"Fleet: {fleet.repo_count} repositories, {fleet.total_findings()} finding(s), "
452
+ f"{len(fleet.links)} cross-repo link(s), "
453
+ f"{len(fleet.cross_boundary)} cross-boundary + {len(fleet.trust_gaps)} trust-gap + "
454
+ f"{len(fleet.cross_repo_anomalies)} anomaly (all speculative). "
455
+ f"Summary written to: {(output_root / summary_name).resolve()}"
456
+ )
457
+ if fleet.failed:
458
+ typer.echo(f"{len(fleet.failed)} repository scan(s) failed: "
459
+ f"{', '.join(f['repo_id'] for f in fleet.failed)}", err=True)
460
+ raise typer.Exit(1)
461
+
462
+
463
+ @app.command()
464
+ def analyze(
465
+ ctx: typer.Context,
466
+ paths: list[str] | None = typer.Argument(
467
+ None,
468
+ help="Path(s) to the repository(ies) to analyze. Pass two or more for a "
469
+ "cross-repo fleet scan (#146a): each is scanned into its own report "
470
+ "subdirectory and a fleet summary is written alongside. Defaults to '.'.",
471
+ ),
472
+ output: str = typer.Option("reports", "--output", "-o", help="Directory for generated reports."),
473
+ format: str = typer.Option(
474
+ "all",
475
+ "--format",
476
+ help="Which report artifacts to write: 'all' (default), 'json' (machine-readable "
477
+ "*.json + SARIF), or 'markdown' (human-readable *.md + Graphviz *.dot). Opt-in "
478
+ "outputs (--baseline diff, --pr-comment, LLM passes) are always written.",
479
+ ),
480
+ module: list[str] | None = typer.Option(
481
+ None,
482
+ "--module",
483
+ "-m",
484
+ help="Analyzer module(s) to run. Repeat to select multiple. A missing official analyzer is installed (at its pinned commit) only with --install-missing or after an interactive confirmation.",
485
+ ),
486
+ install_missing: bool = typer.Option(
487
+ False,
488
+ "--install-missing",
489
+ help="Install missing official analyzers named by --module, pinned to the commits in attackmap's plugin lock.",
490
+ ),
491
+ trusted_analyzers_only: bool = typer.Option(
492
+ False,
493
+ "--trusted-analyzers-only",
494
+ help=f"Load only official AttackMap analyzer plugins; skip any other installed package that registers one (also via {TRUSTED_ONLY_ENV}=1).",
495
+ ),
496
+ llm: bool = typer.Option(
497
+ False,
498
+ "--llm",
499
+ help="Generate a narrative defensive review by calling Claude with the evidence pack. Auth resolves automatically: ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN → `claude` CLI (subscription auth). Force a backend with --llm-backend.",
500
+ ),
501
+ llm_provider: str = typer.Option(
502
+ "claude",
503
+ "--llm-provider",
504
+ help="LLM provider for --llm/--hunt/--remediate: 'claude' (default, Anthropic) or 'openai' (OpenAI/Codex).",
505
+ ),
506
+ llm_model: str | None = typer.Option(
507
+ None,
508
+ "--llm-model",
509
+ help="Model ID for --llm. Claude provider: e.g. claude-opus-4-8 (default), claude-fable-5, claude-sonnet-5, claude-opus-4-7. OpenAI provider: e.g. gpt-5-codex (default), gpt-5.5, gpt-5.4, gpt-5.4-mini — any model ID is passed through verbatim.",
510
+ ),
511
+ llm_effort: str | None = typer.Option(
512
+ None,
513
+ "--llm-effort",
514
+ help="Effort for --llm: low|medium|high|xhigh|max. Defaults to high. For OpenAI, xhigh/max clamp to high (Responses API reasoning effort tops out at high).",
515
+ ),
516
+ llm_backend: str = typer.Option(
517
+ "auto",
518
+ "--llm-backend",
519
+ help="Which backend --llm uses. Claude: 'auto' tries ANTHROPIC_API_KEY → ANTHROPIC_AUTH_TOKEN → `claude` CLI; 'api' forces the SDK; 'cli' forces the `claude` CLI. OpenAI: 'auto' tries OPENAI_API_KEY then the `codex` CLI; 'api' forces the OpenAI SDK; 'cli' forces `codex exec` (your `codex login` subscription).",
520
+ ),
521
+ llm_speed: str = typer.Option(
522
+ "standard",
523
+ "--llm-speed",
524
+ help="LLM output speed: 'standard' (default) or 'fast'. Fast mode (~2.5x output speed, premium price) applies only to Opus 4.8/4.7 via the API backend; other models/backends fall back to standard.",
525
+ ),
526
+ hunt: bool = typer.Option(
527
+ False,
528
+ "--hunt",
529
+ help="Vulnerability-hypothesis mode (#80): have Claude reason over the full evidence pack as a red-team analyst and propose ranked, human-verifiable exploit-chain HYPOTHESES (leads, not detections) to vulnerability-hypotheses.md. Uses the same LLM auth/backend as --llm.",
530
+ ),
531
+ verify: bool = typer.Option(
532
+ False,
533
+ "--verify",
534
+ help="With --hunt: adjudicate each hypothesis against the actual source at cited locations — CONFIRMED / REFUTED / NEEDS HUMAN REVIEW (#hunt-verify). AttackMap feeds the code excerpts to the model.",
535
+ ),
536
+ verify_votes: int = typer.Option(
537
+ 3,
538
+ "--verify-votes",
539
+ help="With --hunt --verify: number of independent skeptic passes to adjudicate each hypothesis. A lead is CONFIRMED only on a strict majority (NEEDS_REVIEW only if a majority flags the evidence insufficient); ties/uncertainty are REFUTED (#147a). 1 = the classic single-pass verify.",
540
+ ),
541
+ hunt_lenses: int = typer.Option(
542
+ 1,
543
+ "--hunt-lenses",
544
+ help="With --hunt --verify: run N independent generation passes, each specialised in a distinct failure mode (auth-bypass, TOCTOU, IDOR, deserialization, SSRF, secret-misuse), then dedupe across them before verifying (#147b). 1 = a single generalist pass. Costs one extra LLM call per lens.",
545
+ ),
546
+ hunt_rounds: int = typer.Option(
547
+ 1,
548
+ "--hunt-rounds",
549
+ help="With --hunt --verify: loop generation for up to N rounds, accumulating new (deduped) leads while a completeness critic seeds each next round with untried angles; stops early once a round finds nothing new (#147c). 1 = a single round.",
550
+ ),
551
+ hunt_budget: int = typer.Option(
552
+ 0,
553
+ "--hunt-budget",
554
+ help="With --hunt-rounds: stop launching new hunting rounds once this many output tokens have been spent (0 = no budget cap). A guardrail on multi-round cost.",
555
+ ),
556
+ remediate: bool = typer.Option(
557
+ False,
558
+ "--remediate",
559
+ help="Remediation mode (#106): have Claude propose concrete, review-first fixes per finding (suggested diffs / precise instructions, grounded in evidence) to remediation.md. Uses the same LLM auth/backend as --llm.",
560
+ ),
561
+ triage: bool = typer.Option(
562
+ False,
563
+ "--triage",
564
+ help="Triage mode (#145): cluster, de-duplicate, and rank the EXISTING heuristic findings into a prioritized shortlist (citing real finding IDs) at triage.md. LLM-backed when available; degrades to a deterministic score-ordered fallback when no backend is present.",
565
+ ),
566
+ recall: bool = typer.Option(
567
+ False,
568
+ "--recall",
569
+ help="Recall mode (#148a): widen taint discovery — deeper import-hop depth and surfacing reaches the default pass conservatively hides. The extra reach is marked SPECULATIVE and kept out of --fail-on-new-high; pair with --hunt --verify to adjudicate it. Off by default (higher recall, lower precision).",
570
+ ),
571
+ baseline: str | None = typer.Option(
572
+ None,
573
+ "--baseline",
574
+ help="Path to a prior `attackmap-report.json`. When set, emit a diff (new/persisted/resolved) alongside the fresh report.",
575
+ ),
576
+ diff_output: str | None = typer.Option(
577
+ None,
578
+ "--diff-output",
579
+ help="Path to write the Markdown diff. Defaults to <output>/attackmap-diff.md when --baseline is set.",
580
+ ),
581
+ fail_on_new_high: bool = typer.Option(
582
+ False,
583
+ "--fail-on-new-high",
584
+ help="Exit non-zero if the diff introduces any new HIGH-severity findings. Requires --baseline.",
585
+ ),
586
+ suppress_from_ref: str | None = typer.Option(
587
+ None,
588
+ "--suppress-from-ref",
589
+ help="Trust only suppressions that exist at this git ref (e.g. the PR's base branch). Suppress-file entries and inline directives added since are reported as pending and not applied.",
590
+ ),
591
+ allow_pr_suppressions: bool = typer.Option(
592
+ False,
593
+ "--allow-pr-suppressions",
594
+ help="With --suppress-from-ref, apply suppressions added since the ref anyway (they are still listed).",
595
+ ),
596
+ strict_suppressions: bool = typer.Option(
597
+ False,
598
+ "--strict-suppressions",
599
+ help="Exit 2 if any suppression has expired (past its `expires:` / `until=` date).",
600
+ ),
601
+ strict_analyzers: bool = typer.Option(
602
+ False,
603
+ "--strict-analyzers",
604
+ help="Fail fast if any analyzer raises or returns an invalid result, instead of skipping it and listing it under analyzer_errors (for plugin development and CI).",
605
+ ),
606
+ fail_on_new_suppression: bool = typer.Option(
607
+ False,
608
+ "--fail-on-new-suppression",
609
+ help="Exit non-zero if a finding active in the baseline is suppressed in this run (e.g. a suppression added by the PR), or excluded from it by an .attackmap-output marker. Requires --baseline.",
610
+ ),
611
+ secrets_history: int = typer.Option(
612
+ 0,
613
+ "--secrets-history",
614
+ help="Also scan the patches of the last N commits (all refs) for hard-coded secrets, reporting the introducing commit and whether each is still in HEAD. Off by default (0); capped at 5000 commits and 64 MB of patch text. Git runs with hooks, pagers, external diff and textconv disabled.",
615
+ ),
616
+ cve: bool = typer.Option(
617
+ False,
618
+ "--cve",
619
+ help="Cross-reference SBOM entries against OSV.dev and surface vulnerable dependencies as findings. Cached under ~/.attackmap/cache/osv/ (24h TTL). Off by default because it does network I/O.",
620
+ ),
621
+ no_progress: bool = typer.Option(
622
+ False,
623
+ "--no-progress",
624
+ help="Disable the live progress bar / ETA during scanning. Progress auto-disables when stderr isn't a terminal (CI, piped output).",
625
+ ),
626
+ progress_format: str = typer.Option(
627
+ "auto",
628
+ "--progress-format",
629
+ help="Progress reporting: 'auto' (TTY bar when stderr is a terminal), 'tty' (always the bar), 'json' (newline-delimited JSON events on stderr, for GUI/tool front-ends), or 'none'. --no-progress is equivalent to 'none'.",
630
+ ),
631
+ pr_comment: str | None = typer.Option(
632
+ None,
633
+ "--pr-comment",
634
+ help="Write a Markdown PR summary comment to this path (for the GitHub Action / bot to post). Includes the baseline diff and top exploitability when available.",
635
+ ),
636
+ no_suppress: bool = typer.Option(
637
+ False,
638
+ "--no-suppress",
639
+ help="Ignore all suppressions — the .attackmap-suppress.yaml baseline and inline `attackmap:ignore` directives (#144). Use for a full, unfiltered audit.",
640
+ ),
641
+ suppress_file: str | None = typer.Option(
642
+ None,
643
+ "--suppress-file",
644
+ help="Path to a suppression baseline, overriding auto-discovery of .attackmap-suppress.yaml at the repo root (#144).",
645
+ ),
646
+ ) -> None:
647
+ repo_paths = [Path(p).resolve() for p in (paths or ["."])]
648
+ for rp in repo_paths:
649
+ if not rp.exists():
650
+ raise typer.BadParameter(f"Path does not exist: {rp}")
651
+
652
+ # Validate progress format before either branch, so the fleet path enforces
653
+ # the same option semantics as a single-repo run (#146a).
654
+ if trusted_analyzers_only and os.environ.get(TRUSTED_ONLY_ENV) != "1":
655
+ previous = os.environ.get(TRUSTED_ONLY_ENV)
656
+ os.environ[TRUSTED_ONLY_ENV] = "1"
657
+
658
+ def _restore_trust_env() -> None:
659
+ if previous is None:
660
+ os.environ.pop(TRUSTED_ONLY_ENV, None)
661
+ else:
662
+ os.environ[TRUSTED_ONLY_ENV] = previous
663
+
664
+ ctx.call_on_close(_restore_trust_env)
665
+ _validate_analyze_options(
666
+ ctx,
667
+ progress_format=progress_format,
668
+ output_format=format,
669
+ llm_provider=llm_provider,
670
+ llm_speed=llm_speed,
671
+ llm_effort=llm_effort,
672
+ llm_backend=llm_backend,
673
+ hunt=hunt,
674
+ verify=verify,
675
+ verify_votes=verify_votes,
676
+ hunt_lenses=hunt_lenses,
677
+ hunt_rounds=hunt_rounds,
678
+ hunt_budget=hunt_budget,
679
+ baseline=baseline,
680
+ diff_output=diff_output,
681
+ secrets_history=secrets_history,
682
+ )
683
+
684
+ # Multi-repo fleet mode (#146a): scan each repo independently and assemble a
685
+ # fleet view. Dispatched here so the single-repo path below is untouched.
686
+ if len(repo_paths) > 1:
687
+ _run_fleet(
688
+ repo_paths,
689
+ output=output,
690
+ module=module,
691
+ recall=recall,
692
+ cve=cve,
693
+ no_suppress=no_suppress,
694
+ suppress_file=suppress_file,
695
+ progress_format=progress_format,
696
+ no_progress=no_progress,
697
+ output_format=format,
698
+ install_missing=install_missing,
699
+ suppress_from_ref=suppress_from_ref,
700
+ strict_analyzers=strict_analyzers,
701
+ # Single-repo-only features aren't fleet-aware yet — reject rather
702
+ # than silently ignore, so the user isn't surprised (#146b+ wire them).
703
+ fleet_incompatible={
704
+ "--baseline": baseline is not None,
705
+ "--diff-output": diff_output is not None,
706
+ "--fail-on-new-high": fail_on_new_high,
707
+ "--fail-on-new-suppression": fail_on_new_suppression,
708
+ "--pr-comment": pr_comment is not None,
709
+ "--llm": llm,
710
+ "--hunt": hunt,
711
+ "--remediate": remediate,
712
+ "--triage": triage,
713
+ "--secrets-history": secrets_history > 0,
714
+ },
715
+ )
716
+ return
717
+
718
+ repo_path = repo_paths[0]
719
+ # Validate diff-mode flag combinations before doing any real work.
720
+ if fail_on_new_high and baseline is None:
721
+ raise typer.BadParameter("--fail-on-new-high requires --baseline to be set.")
722
+ if fail_on_new_suppression and baseline is None:
723
+ raise typer.BadParameter("--fail-on-new-suppression requires --baseline to be set.")
724
+ if baseline is not None:
725
+ baseline_path = Path(baseline)
726
+ if not baseline_path.exists():
727
+ raise typer.BadParameter(f"Baseline report not found: {baseline_path}")
728
+
729
+ selected_analyzers = None
730
+ if module:
731
+ selected_analyzers = _select_modules(module, install_missing)
732
+
733
+ # (Enum/range/dependency options are validated up front, #229.)
734
+ # Provider-aware label for the "via X" progress/echo strings.
735
+ llm_display = "Codex" if llm_provider == "openai" else "Claude"
736
+ if llm_provider == "openai" and llm_speed == "fast" and (llm or hunt or remediate):
737
+ typer.echo(
738
+ "Note: --llm-speed fast is Claude-only; ignoring it for the OpenAI provider.",
739
+ err=True,
740
+ )
741
+ opt_in_matches: list[str] = []
742
+ active_analyzers = resolve_run_analyzers(
743
+ repo_path, analyzers=selected_analyzers, opt_in_matches=opt_in_matches
744
+ )
745
+ _echo_opt_in_hint(opt_in_matches)
746
+ scan_progress = create_progress(progress_format, no_progress=no_progress)
747
+ _distrust_pr_markers(repo_path, suppress_from_ref, Path(output))
748
+ scan = analyze_repository(
749
+ repo_path,
750
+ analyzers=active_analyzers,
751
+ progress=scan_progress,
752
+ recall=recall,
753
+ strict=strict_analyzers,
754
+ )
755
+ if recall:
756
+ speculative = sum(1 for c in scan.taint_chains if c.speculative)
757
+ typer.echo(
758
+ f"Recall mode: widened taint discovery, {speculative} speculative "
759
+ "chain(s) surfaced (marked low-confidence; adjudicate with --hunt --verify)."
760
+ )
761
+ if secrets_history:
762
+ from .secrets_history import scan_secret_history
763
+
764
+ typer.echo(f"Scanning the last {secrets_history} commit(s) for secrets in git history…")
765
+ history_hits, history_notes = scan_secret_history(repo_path, secrets_history)
766
+ scan.secret_history = history_hits
767
+ scan.limitations.extend(history_notes)
768
+ removed = sum(1 for h in history_hits if not h.still_in_head)
769
+ typer.echo(
770
+ f"Secrets in history: {len(history_hits)} found, {removed} no longer in HEAD."
771
+ )
772
+ if cve and scan.dependencies:
773
+ typer.echo(f"Checking {len(scan.dependencies)} dependencies against OSV.dev…")
774
+ # query_vulnerabilities drives a determinate begin/advance bar itself
775
+ # (per unique package), so big lockfile trees show real progress + ETA
776
+ # instead of an indeterminate spinner.
777
+ try:
778
+ vulns, cve_summary = query_vulnerabilities(
779
+ scan.dependencies, progress=scan_progress
780
+ )
781
+ finally:
782
+ scan_progress.done()
783
+ scan.vulnerabilities = vulns
784
+ typer.echo(
785
+ f"CVE lookup: {len(vulns)} vulnerabilit"
786
+ f"{'y' if len(vulns) == 1 else 'ies'} across "
787
+ f"{cve_summary.queried} query(s), {cve_summary.cached} cache hit(s), "
788
+ f"{cve_summary.skipped_no_version} skipped (no queryable version), "
789
+ f"{cve_summary.network_errors} network error(s)."
790
+ )
791
+ if cve_summary.inventory_only:
792
+ listed = ", ".join(f"{n} {eco}" for eco, n in sorted(cve_summary.inventory_only.items()))
793
+ typer.echo(
794
+ f"Not CVE-checked (no OSV.dev database for the ecosystem; inventory only): {listed}."
795
+ )
796
+ graph = build_graph(scan)
797
+ analysis = translate_recon(scan)
798
+ attack_surfaces = analysis.attack_surfaces
799
+ architecture_md = summarize_architecture(scan, graph)
800
+ attack_surface_md = summarize_attack_surface(scan, attack_surfaces)
801
+ findings = analysis.findings
802
+ attack_paths = analysis.attack_paths
803
+
804
+ # Finding suppression (#144): the .attackmap-suppress.yaml baseline plus
805
+ # inline `attackmap:ignore` directives. Suppressed findings are partitioned
806
+ # out of the active set used for the review, console, and the diff gate —
807
+ # but retained (with reasons) in report.json and marked suppressed in SARIF.
808
+ suppressed_findings: list = []
809
+ suppression_governance: dict | None = None
810
+ if no_suppress:
811
+ if suppress_file is not None:
812
+ typer.echo("Note: --suppress-file is ignored because --no-suppress is set.", err=True)
813
+ else:
814
+ explicit = Path(suppress_file) if suppress_file else None
815
+ if explicit is not None and not explicit.exists():
816
+ raise typer.BadParameter(f"Suppress file not found: {explicit}")
817
+ try:
818
+ suppset, sup_warnings = collect_suppressions(
819
+ repo_path,
820
+ findings,
821
+ explicit_file=explicit,
822
+ trusted_ref=suppress_from_ref,
823
+ allow_pr_suppressions=allow_pr_suppressions,
824
+ )
825
+ except GitRefError as exc:
826
+ raise typer.BadParameter(str(exc)) from exc
827
+ for warning in sup_warnings:
828
+ typer.echo(f"Suppression warning: {warning}", err=True)
829
+ outcome = apply_suppressions(findings, suppset)
830
+ for notice in outcome.notices:
831
+ typer.echo(f"Suppression warning: {notice}", err=True)
832
+ findings = outcome.active
833
+ suppressed_findings = outcome.suppressed
834
+ suppression_governance = {
835
+ "pending": [f"{sup.label()} — {sup.reason} ({sup.source})" for sup in suppset.pending],
836
+ "pending_applied": suppset.pending_applied,
837
+ "trusted_ref": suppress_from_ref,
838
+ "by_rule": dict(Counter(s.rule for s in suppressed_findings)),
839
+ }
840
+ if suppset.pending:
841
+ verb = "applied anyway (--allow-pr-suppressions)" if suppset.pending_applied else "NOT applied"
842
+ typer.echo(
843
+ f"{len(suppset.pending)} suppression(s) added since {suppress_from_ref} — {verb}:", err=True
844
+ )
845
+ for line in suppression_governance["pending"]:
846
+ typer.echo(f" - {line}", err=True)
847
+ if strict_suppressions and suppset.expired:
848
+ typer.echo(
849
+ f"Error: {len(suppset.expired)} expired suppression(s) (--strict-suppressions).", err=True
850
+ )
851
+ raise typer.Exit(2)
852
+ if suppressed_findings:
853
+ typer.echo("")
854
+ typer.echo(f"Suppressed {len(suppressed_findings)} finding(s) (#144):")
855
+ for summary_line in outcome.summary_lines():
856
+ typer.echo(summary_line)
857
+
858
+ defensive_review_md = render_defensive_review(scan, attack_surfaces, findings, attack_paths)
859
+
860
+ try:
861
+ write_reports(
862
+ output,
863
+ scan,
864
+ architecture_md,
865
+ attack_surface_md,
866
+ defensive_review_md,
867
+ attack_surfaces,
868
+ findings,
869
+ attack_paths,
870
+ analyzer_metadata=[
871
+ {
872
+ "name": metadata.name,
873
+ "description": metadata.description,
874
+ "scope": metadata.scope,
875
+ "ecosystems": list(metadata.ecosystems),
876
+ }
877
+ for metadata in (get_analyzer_metadata(analyzer) for analyzer in active_analyzers)
878
+ ],
879
+ suppressed=suppressed_findings,
880
+ output_format=format,
881
+ )
882
+ except UnsafePathError as exc:
883
+ typer.echo(f"Error: {exc}", err=True)
884
+ raise typer.Exit(2) from exc
885
+ typer.echo(render_console_summary(scan, findings, attack_paths))
886
+ _warn_if_nothing_scanned(scan)
887
+ typer.echo("")
888
+ typer.echo(f"Reports written to: {Path(output).resolve()}")
889
+
890
+ diff_exit_code = 0
891
+ diff = None
892
+ if baseline is not None:
893
+ baseline_path = Path(baseline)
894
+ try:
895
+ baseline_snapshots = load_baseline(baseline_path)
896
+ except (OSError, ValueError) as exc:
897
+ raise typer.BadParameter(f"Failed to read baseline: {exc}") from exc
898
+ current_snapshots = [FindingSnapshot.from_finding(f) for f in findings]
899
+ diff = diff_findings(
900
+ baseline_snapshots,
901
+ current_snapshots,
902
+ suppressed=[FindingSnapshot.from_finding(s.finding) for s in suppressed_findings],
903
+ excluded_dirs=_excluded_dirs(scan),
904
+ )
905
+ counts = diff.counts()
906
+ typer.echo("")
907
+ typer.echo(
908
+ f"Diff vs baseline: {counts['new']} new, "
909
+ f"{counts['new_instances']} new instance(s) of existing findings, "
910
+ f"{counts['persisted']} persisted, {counts['resolved']} resolved, "
911
+ f"{counts['newly_suppressed']} newly suppressed"
912
+ + (f", {counts['newly_excluded']} newly excluded from the scan." if diff.newly_excluded else ".")
913
+ )
914
+ if diff.baseline_without_instances:
915
+ typer.echo(
916
+ "Note: the baseline predates per-instance fingerprints (attackmap < 0.4.32); "
917
+ "new instances of existing findings can't be detected until it is regenerated.",
918
+ err=True,
919
+ )
920
+ diff_path = Path(diff_output) if diff_output else Path(output) / "attackmap-diff.md"
921
+ _write_text(diff_path, render_diff_markdown(diff))
922
+ typer.echo(f"Diff written to: {diff_path.resolve()}")
923
+ if fail_on_new_high and diff.has_new_high:
924
+ new_high_titles = [s.title for s in diff.new if s.severity == "high"] + [
925
+ f"{s.title} (new instance(s): {', '.join(labels[:5])})"
926
+ for s, labels in diff.new_instances
927
+ if s.severity == "high"
928
+ ]
929
+ typer.echo("", err=True)
930
+ typer.echo(
931
+ "New HIGH findings introduced (failing per --fail-on-new-high):",
932
+ err=True,
933
+ )
934
+ for t in new_high_titles:
935
+ typer.echo(f" - {t}", err=True)
936
+ diff_exit_code = 1
937
+ if fail_on_new_suppression and diff.newly_suppressed:
938
+ typer.echo("", err=True)
939
+ typer.echo(
940
+ "Findings newly suppressed by this change (failing per --fail-on-new-suppression):",
941
+ err=True,
942
+ )
943
+ for s_ in diff.newly_suppressed:
944
+ typer.echo(f" - [{s_.severity.upper()}] {s_.title}", err=True)
945
+ diff_exit_code = 1
946
+ if fail_on_new_suppression and diff.newly_excluded:
947
+ typer.echo("", err=True)
948
+ typer.echo(
949
+ "Findings newly excluded from the scan by an output marker "
950
+ "(failing per --fail-on-new-suppression):",
951
+ err=True,
952
+ )
953
+ for s_ in diff.newly_excluded:
954
+ typer.echo(f" - [{s_.severity.upper()}] {s_.title}", err=True)
955
+ diff_exit_code = 1
956
+
957
+ if pr_comment is not None:
958
+ pr_path = Path(pr_comment)
959
+ _write_text(pr_path, render_pr_comment(findings, diff, suppressions=suppression_governance))
960
+ typer.echo(f"PR comment written to: {pr_path.resolve()}")
961
+
962
+ if llm:
963
+ try:
964
+ effort_value = llm_effort # validated up front (#229)
965
+
966
+ typer.echo("")
967
+ typer.echo(
968
+ f"Generating narrative review via {llm_display} (backend={llm_backend}, may take a minute)..."
969
+ )
970
+ scan_progress.stage(
971
+ f"{llm_display} is writing the defensive review (backend={llm_backend})"
972
+ )
973
+ try:
974
+ result = generate_llm_review(
975
+ scan,
976
+ attack_surfaces,
977
+ findings,
978
+ attack_paths,
979
+ model=llm_model,
980
+ effort=effort_value, # type: ignore[arg-type]
981
+ backend=llm_backend, # type: ignore[arg-type]
982
+ speed=llm_speed, # type: ignore[arg-type]
983
+ provider=llm_provider, # type: ignore[arg-type]
984
+ )
985
+ finally:
986
+ scan_progress.done()
987
+ except LlmReviewError as exc:
988
+ typer.echo(f"LLM review skipped: {exc}", err=True)
989
+ else:
990
+ output_path = Path(output)
991
+ llm_md_path = output_path / "defensive-review-llm.md"
992
+ _write_text(llm_md_path, sanitize_llm_markdown(result.markdown) + "\n")
993
+ llm_meta_path = output_path / "defensive-review-llm.meta.json"
994
+ _write_text(
995
+ llm_meta_path,
996
+ json.dumps(
997
+ {
998
+ "backend": result.backend,
999
+ "model": result.model,
1000
+ "stop_reason": result.stop_reason,
1001
+ "usage": result.usage,
1002
+ },
1003
+ indent=2,
1004
+ )
1005
+ + "\n",
1006
+ )
1007
+ typer.echo(f"LLM review written to: {llm_md_path.resolve()} (backend={result.backend})")
1008
+
1009
+ if hunt:
1010
+ hunt_effort_value = llm_effort # validated up front (#229)
1011
+ typer.echo("")
1012
+ # Multi-pass hunt harness (#147a/#147b): opt in with --verify and either
1013
+ # --verify-votes > 1 (jury) or --hunt-lenses > 1 (multi-lens generation).
1014
+ # Ranges are validated up front (#229); no silent clamping.
1015
+ lens_count = hunt_lenses
1016
+ jury_votes = verify_votes
1017
+ max_rounds = hunt_rounds
1018
+ token_budget = hunt_budget if hunt_budget > 0 else None
1019
+ use_jury = verify and (jury_votes > 1 or lens_count > 1 or max_rounds > 1)
1020
+ lens_names = [name for name, _ in HUNT_LENSES][:lens_count] if lens_count > 1 else None
1021
+
1022
+ def _hunt_llm_call(mode: str, hypotheses=None, lens=None, avoid_titles=None, critic_hint=None):
1023
+ return generate_llm_review(
1024
+ scan,
1025
+ attack_surfaces,
1026
+ findings,
1027
+ attack_paths,
1028
+ model=llm_model,
1029
+ effort=hunt_effort_value, # type: ignore[arg-type]
1030
+ backend=llm_backend, # type: ignore[arg-type]
1031
+ mode=mode, # type: ignore[arg-type]
1032
+ speed=llm_speed, # type: ignore[arg-type]
1033
+ provider=llm_provider, # type: ignore[arg-type]
1034
+ hypotheses=hypotheses,
1035
+ lens=lens,
1036
+ avoid_titles=avoid_titles,
1037
+ critic_hint=critic_hint,
1038
+ )
1039
+
1040
+ hunt_extra_meta: dict = {}
1041
+ try:
1042
+ if use_jury:
1043
+ lens_note = f", {lens_count} lenses" if lens_names else ""
1044
+ round_note = f", up to {max_rounds} rounds" if max_rounds > 1 else ""
1045
+ typer.echo(
1046
+ f"Hunting + adjudicating via {llm_display} "
1047
+ f"({jury_votes} independent skeptics{lens_note}{round_note}, backend={llm_backend}, may take a few minutes)..."
1048
+ )
1049
+ scan_progress.stage(
1050
+ f"{llm_display} is hunting + majority-vote verifying "
1051
+ f"({jury_votes} skeptics{lens_note}{round_note}, backend={llm_backend})"
1052
+ )
1053
+ try:
1054
+ jury = run_majority_verify(
1055
+ scan, attack_surfaces, findings, attack_paths,
1056
+ votes=jury_votes, llm_call=_hunt_llm_call, lenses=lens_names,
1057
+ max_rounds=max_rounds, token_budget=token_budget,
1058
+ )
1059
+ finally:
1060
+ scan_progress.done()
1061
+ hunt_markdown = jury.report
1062
+ hunt_backend, hunt_model = jury.backend, jury.model
1063
+ hunt_stop, hunt_usage = None, jury.usage
1064
+ hunt_extra_meta = {
1065
+ "verify_votes": jury.votes,
1066
+ "lenses": lens_names or [],
1067
+ "rounds_run": jury.rounds,
1068
+ "hypothesis_count": jury.hypothesis_count,
1069
+ "consensus": {
1070
+ "confirmed": sum(1 for c in jury.consensus if c.verdict == "confirmed"),
1071
+ "needs_review": sum(1 for c in jury.consensus if c.verdict == "needs_review"),
1072
+ "refuted": sum(1 for c in jury.consensus if c.verdict == "refuted"),
1073
+ },
1074
+ }
1075
+ else:
1076
+ hunt_mode = "hunt_verify" if verify else "hunt"
1077
+ typer.echo(
1078
+ f"Hunting for vulnerability hypotheses via {llm_display} "
1079
+ f"({'adjudicated against source, ' if verify else ''}backend={llm_backend}, may take a minute)..."
1080
+ )
1081
+ scan_progress.stage(
1082
+ f"{llm_display} is hunting exploit-chain hypotheses"
1083
+ f"{' + verifying against source' if verify else ''} (backend={llm_backend})"
1084
+ )
1085
+ try:
1086
+ hunt_result = _hunt_llm_call(hunt_mode)
1087
+ finally:
1088
+ scan_progress.done()
1089
+ hunt_markdown = hunt_result.markdown
1090
+ hunt_backend, hunt_model = hunt_result.backend, hunt_result.model
1091
+ hunt_stop, hunt_usage = hunt_result.stop_reason, hunt_result.usage
1092
+ except LlmReviewError as exc:
1093
+ typer.echo(f"Vulnerability hunt skipped: {exc}", err=True)
1094
+ else:
1095
+ output_path = Path(output)
1096
+ hunt_md_path = output_path / "vulnerability-hypotheses.md"
1097
+ _write_text(hunt_md_path, HUNT_BANNER + sanitize_llm_markdown(hunt_markdown) + "\n")
1098
+ hunt_meta_path = output_path / "vulnerability-hypotheses.meta.json"
1099
+ _write_text(
1100
+ hunt_meta_path,
1101
+ json.dumps(
1102
+ {
1103
+ "backend": hunt_backend,
1104
+ "model": hunt_model,
1105
+ "stop_reason": hunt_stop,
1106
+ "usage": hunt_usage,
1107
+ **hunt_extra_meta,
1108
+ },
1109
+ indent=2,
1110
+ )
1111
+ + "\n",
1112
+ )
1113
+ typer.echo(
1114
+ f"Vulnerability hypotheses written to: {hunt_md_path.resolve()} "
1115
+ f"(backend={hunt_backend})"
1116
+ )
1117
+
1118
+ if remediate:
1119
+ try:
1120
+ rem_effort_value = llm_effort # validated up front (#229)
1121
+ typer.echo("")
1122
+ typer.echo(
1123
+ f"Generating remediation suggestions via {llm_display} (backend={llm_backend}, may take a minute)..."
1124
+ )
1125
+ scan_progress.stage(
1126
+ f"{llm_display} is drafting remediation suggestions (backend={llm_backend})"
1127
+ )
1128
+ try:
1129
+ rem_result = generate_llm_review(
1130
+ scan,
1131
+ attack_surfaces,
1132
+ findings,
1133
+ attack_paths,
1134
+ model=llm_model,
1135
+ effort=rem_effort_value, # type: ignore[arg-type]
1136
+ backend=llm_backend, # type: ignore[arg-type]
1137
+ mode="remediate",
1138
+ speed=llm_speed, # type: ignore[arg-type]
1139
+ provider=llm_provider, # type: ignore[arg-type]
1140
+ )
1141
+ finally:
1142
+ scan_progress.done()
1143
+ except LlmReviewError as exc:
1144
+ typer.echo(f"Remediation skipped: {exc}", err=True)
1145
+ else:
1146
+ output_path = Path(output)
1147
+ rem_md_path = output_path / "remediation.md"
1148
+ _write_text(rem_md_path, REMEDIATION_BANNER + sanitize_llm_markdown(rem_result.markdown) + "\n")
1149
+ rem_meta_path = output_path / "remediation.meta.json"
1150
+ _write_text(
1151
+ rem_meta_path,
1152
+ json.dumps(
1153
+ {
1154
+ "backend": rem_result.backend,
1155
+ "model": rem_result.model,
1156
+ "stop_reason": rem_result.stop_reason,
1157
+ "usage": rem_result.usage,
1158
+ },
1159
+ indent=2,
1160
+ )
1161
+ + "\n",
1162
+ )
1163
+ typer.echo(
1164
+ f"Remediation suggestions written to: {rem_md_path.resolve()} "
1165
+ f"(backend={rem_result.backend})"
1166
+ )
1167
+
1168
+ if triage:
1169
+ tri_effort_value = llm_effort # validated up front (#229)
1170
+ output_path = Path(output)
1171
+ tri_md_path = output_path / "triage.md"
1172
+ tri_meta_path = output_path / "triage.meta.json"
1173
+ tri_backend = "deterministic"
1174
+ tri_model: str | None = None
1175
+ tri_stop: str | None = None
1176
+ tri_usage: dict = {}
1177
+ try:
1178
+ typer.echo("")
1179
+ typer.echo(
1180
+ f"Triaging findings via {llm_display} (backend={llm_backend})..."
1181
+ )
1182
+ scan_progress.stage(f"{llm_display} is triaging findings (backend={llm_backend})")
1183
+ try:
1184
+ tri_result = generate_llm_review(
1185
+ scan,
1186
+ attack_surfaces,
1187
+ findings,
1188
+ attack_paths,
1189
+ model=llm_model,
1190
+ effort=tri_effort_value, # type: ignore[arg-type]
1191
+ backend=llm_backend, # type: ignore[arg-type]
1192
+ mode="triage",
1193
+ speed=llm_speed, # type: ignore[arg-type]
1194
+ provider=llm_provider, # type: ignore[arg-type]
1195
+ )
1196
+ finally:
1197
+ scan_progress.done()
1198
+ markdown = TRIAGE_BANNER + tri_result.markdown + "\n"
1199
+ tri_backend = tri_result.backend
1200
+ tri_model = tri_result.model
1201
+ tri_stop = tri_result.stop_reason
1202
+ tri_usage = tri_result.usage
1203
+ except LlmReviewError as exc:
1204
+ # No LLM backend / call failed — degrade to the deterministic,
1205
+ # reproducible score-ordered shortlist rather than erroring (#145).
1206
+ typer.echo(f"Triage LLM unavailable ({exc}); using deterministic ordering.", err=True)
1207
+ markdown = TRIAGE_BANNER + render_triage_fallback(scan, findings) + "\n"
1208
+ _write_text(tri_md_path, sanitize_llm_markdown(markdown))
1209
+ _write_text(
1210
+ tri_meta_path,
1211
+ json.dumps(
1212
+ {
1213
+ "backend": tri_backend,
1214
+ "model": tri_model,
1215
+ "stop_reason": tri_stop,
1216
+ "usage": tri_usage,
1217
+ },
1218
+ indent=2,
1219
+ )
1220
+ + "\n",
1221
+ )
1222
+ typer.echo(f"Triage shortlist written to: {tri_md_path.resolve()} (backend={tri_backend})")
1223
+
1224
+ if diff_exit_code:
1225
+ raise typer.Exit(code=diff_exit_code)
1226
+
1227
+
1228
+ CAPABILITIES_SCHEMA_VERSION = 1
1229
+
1230
+
1231
+ @app.command("capabilities")
1232
+ def capabilities() -> None:
1233
+ """Describe what this CLI supports, for GUIs and wrappers.
1234
+
1235
+ Front-ends (e.g. the macOS app) used to scrape `analyze --help`, which
1236
+ breaks when help wording or terminal width changes. This prints a stable
1237
+ JSON document instead: the version, the subcommands, every long option
1238
+ `analyze` accepts, and whether `analyze` takes multiple repo paths.
1239
+ """
1240
+ root = typer.main.get_command(app)
1241
+ commands = getattr(root, "commands", {})
1242
+ analyze_cmd = commands.get("analyze")
1243
+ options: set[str] = set()
1244
+ multi_repo = False
1245
+ for param in getattr(analyze_cmd, "params", []):
1246
+ for opt in [*getattr(param, "opts", []), *getattr(param, "secondary_opts", [])]:
1247
+ if opt.startswith("--"):
1248
+ options.add(opt)
1249
+ if getattr(param, "param_type_name", "") == "argument" and getattr(param, "nargs", 1) == -1:
1250
+ multi_repo = True
1251
+ payload = {
1252
+ "schema": CAPABILITIES_SCHEMA_VERSION,
1253
+ "version": __version__,
1254
+ "commands": sorted(commands),
1255
+ "analyze": {"options": sorted(options), "multi_repo": multi_repo},
1256
+ "progress_protocol": PROGRESS_PROTOCOL_VERSION,
1257
+ }
1258
+ typer.echo(json.dumps(payload, indent=2))
1259
+
1260
+
1261
+ @app.command("modules")
1262
+ def modules(
1263
+ json_output: bool = typer.Option(
1264
+ False,
1265
+ "--json",
1266
+ help="Emit the installed analyzer modules as a JSON array "
1267
+ "(name/display_name/description/scope/ecosystems/enabled_by_default). "
1268
+ "Network-free — the remote module-repository section is omitted. "
1269
+ "Intended for tool / GUI front-ends that offer analyzer selection.",
1270
+ ),
1271
+ ) -> None:
1272
+ available_modules = get_available_modules()
1273
+ if json_output:
1274
+ payload = [
1275
+ {
1276
+ "name": meta.name,
1277
+ "display_name": meta.display_name or meta.name,
1278
+ "description": meta.description,
1279
+ "scope": meta.scope,
1280
+ "ecosystems": list(meta.ecosystems),
1281
+ "enabled_by_default": meta.enabled_by_default,
1282
+ "priority": meta.priority,
1283
+ "experimental": meta.experimental,
1284
+ }
1285
+ for meta in available_modules
1286
+ ]
1287
+ typer.echo(json.dumps(payload, indent=2))
1288
+ return
1289
+
1290
+ if not available_modules:
1291
+ typer.echo("No analyzer modules are currently available.")
1292
+ else:
1293
+ typer.echo("Available analyzer modules (installed, in run order):")
1294
+ for module_metadata in available_modules:
1295
+ ecosystems = ", ".join(module_metadata.ecosystems) if module_metadata.ecosystems else "none"
1296
+ mode = "default" if module_metadata.enabled_by_default else f"opt-in (-m {module_metadata.name})"
1297
+ typer.echo(f"- {module_metadata.name}: {module_metadata.description}")
1298
+ typer.echo(f" scope: {module_metadata.scope}")
1299
+ typer.echo(f" ecosystems: {ecosystems}")
1300
+ typer.echo(f" priority: {module_metadata.priority} runs: {mode}")
1301
+ if module_metadata.experimental and module_metadata.enabled_by_default:
1302
+ typer.echo(" note: experimental but enabled by default")
1303
+
1304
+ typer.echo("")
1305
+ typer.echo("Available module repositories (mlaify GitHub org):")
1306
+ try:
1307
+ repository_modules = get_available_repository_modules()
1308
+ except ValueError as exc:
1309
+ typer.echo(f"- Unable to fetch remote module repositories: {exc}")
1310
+ return
1311
+
1312
+ if not repository_modules:
1313
+ typer.echo("- No module repositories were discovered.")
1314
+ return
1315
+
1316
+ for module in repository_modules:
1317
+ typer.echo(f"- {module.analyzer_name} ({module.repo_name})")
1318
+ typer.echo(f" repo: {module.web_url}")
1319
+
1320
+
1321
+ @app.command("suggest")
1322
+ def suggest(
1323
+ path: str = typer.Argument(".", help="Path to the repository to inspect."),
1324
+ install: bool = typer.Option(
1325
+ False,
1326
+ "--install",
1327
+ help="After printing, offer to `pip install` the missing recommended plugins.",
1328
+ ),
1329
+ yes: bool = typer.Option(
1330
+ False,
1331
+ "--yes",
1332
+ "-y",
1333
+ help="Skip the confirmation prompt when used with --install.",
1334
+ ),
1335
+ show_installed: bool = typer.Option(
1336
+ False,
1337
+ "--show-installed",
1338
+ help="Include already-installed plugins in the output (marked accordingly).",
1339
+ ),
1340
+ ) -> None:
1341
+ """Recommend analyzer plugins for the shape of the given repository."""
1342
+ repo = Path(path).resolve()
1343
+ if not repo.exists() or not repo.is_dir():
1344
+ typer.echo(f"Path does not exist or is not a directory: {repo}")
1345
+ raise typer.Exit(code=1)
1346
+
1347
+ suggestions = detect_ecosystems(repo)
1348
+ missing = [s for s in suggestions if not s.installed]
1349
+ installed = [s for s in suggestions if s.installed]
1350
+
1351
+ if not suggestions:
1352
+ typer.echo(f"No AttackMap plugins matched the shape of {repo}.")
1353
+ raise typer.Exit(code=0)
1354
+
1355
+ shown = suggestions if show_installed else missing
1356
+ if not shown:
1357
+ typer.echo("All recommended plugins are already installed:")
1358
+ for s in installed:
1359
+ typer.echo(f" - {s.plugin} (installed)")
1360
+ raise typer.Exit(code=0)
1361
+
1362
+ typer.echo(f"Recommended plugins for {repo}:")
1363
+ for s in shown:
1364
+ tag = " (installed)" if s.installed else ""
1365
+ typer.echo(f" - {s.plugin}{tag}")
1366
+ typer.echo(f" matches: {', '.join(s.matched_signals)}")
1367
+ if not s.installed:
1368
+ typer.echo(f" install: {s.pip_install}")
1369
+
1370
+ if not install:
1371
+ return
1372
+ if not missing:
1373
+ typer.echo("Nothing to install — all recommended plugins are already present.")
1374
+ return
1375
+
1376
+ typer.echo("")
1377
+ typer.echo("The following plugins will be installed via pip:")
1378
+ for s in missing:
1379
+ typer.echo(f" - {s.plugin}")
1380
+ if not yes and not typer.confirm("Proceed?", default=False):
1381
+ typer.echo("Aborted.")
1382
+ raise typer.Exit(code=1)
1383
+
1384
+ for s in missing:
1385
+ typer.echo(f"Installing {s.plugin}…")
1386
+ try:
1387
+ install_analyzer_module(s.plugin)
1388
+ except Exception as exc: # noqa: BLE001 — surface pip failures verbatim
1389
+ typer.echo(f" Failed: {exc}")
1390
+ raise typer.Exit(code=1) from exc
1391
+ typer.echo("Done.")
1392
+
1393
+
1394
+ @app.command("rules")
1395
+ def rules(
1396
+ as_json: bool = typer.Option(False, "--json", help="Emit the rule catalog as JSON."),
1397
+ ) -> None:
1398
+ """List every core detector's stable rule id (#223) — the SARIF ruleId and
1399
+ the value for `rule:` suppressions and `attackmap:ignore[...]`."""
1400
+ from .threat_model import rule_catalog
1401
+
1402
+ catalog = rule_catalog()
1403
+ if as_json:
1404
+ typer.echo(json.dumps([{"rule_id": r, "title": t, "severity": sev} for r, t, sev in catalog], indent=2))
1405
+ return
1406
+ width = max(len(r) for r, _, _ in catalog)
1407
+ for rule_id, title, severity in catalog:
1408
+ typer.echo(f"{rule_id.ljust(width)} {severity:<7} {title}")
1409
+
1410
+
1411
+ @app.command("bench")
1412
+ def bench(
1413
+ benchmark: str = typer.Option(
1414
+ "evals/benchmark/benchmark.json",
1415
+ "--benchmark",
1416
+ help="Path to the benchmark manifest (labeled ground-truth corpus).",
1417
+ ),
1418
+ root: str = typer.Option(
1419
+ ".",
1420
+ "--root",
1421
+ help="Repository root the manifest's case paths are relative to.",
1422
+ ),
1423
+ output: str | None = typer.Option(
1424
+ None,
1425
+ "--output",
1426
+ "-o",
1427
+ help="Directory to write benchmark-results.md + .json (also printed to stdout).",
1428
+ ),
1429
+ fail_under: float | None = typer.Option(
1430
+ None,
1431
+ "--fail-under",
1432
+ help="Exit non-zero if any scored detector class falls below this precision/recall (0-1). For CI regression gating.",
1433
+ ),
1434
+ ) -> None:
1435
+ """Score AttackMap findings against the labeled benchmark corpus (#197).
1436
+
1437
+ The 1.0 precision gate: reports precision/recall/F1 per detector class.
1438
+ Contributor/CI tool — run from the repo root (the manifest ships in `evals/`,
1439
+ not the installed wheel).
1440
+ """
1441
+ from .bench import bench_json, load_benchmark, min_scored_metric, render_markdown, run_benchmark
1442
+
1443
+ manifest = Path(benchmark)
1444
+ if not manifest.exists():
1445
+ typer.echo(f"Benchmark manifest not found: {manifest}")
1446
+ typer.echo("Pass --benchmark <path>, or run from a checkout that has evals/benchmark/.")
1447
+ raise typer.Exit(code=2)
1448
+
1449
+ results = run_benchmark(load_benchmark(manifest), root)
1450
+ markdown = render_markdown(results)
1451
+ typer.echo(markdown)
1452
+
1453
+ if output:
1454
+ out = Path(output)
1455
+ _ensure_output_dir(out)
1456
+ _write_text(out / "benchmark-results.md", markdown + "\n")
1457
+ _write_text(out / "benchmark-results.json", json.dumps(bench_json(results), indent=2) + "\n")
1458
+ typer.echo(f"Written to: {out.resolve()}")
1459
+
1460
+ errored = [r.id for r in results if r.error]
1461
+ if errored:
1462
+ typer.echo(f"Cases that failed to scan: {', '.join(errored)}")
1463
+ raise typer.Exit(code=1)
1464
+
1465
+ if fail_under is not None:
1466
+ worst = min_scored_metric(results)
1467
+ if worst < fail_under:
1468
+ typer.echo(f"FAIL: lowest scored precision/recall {worst:.2f} < --fail-under {fail_under:.2f}")
1469
+ raise typer.Exit(code=1)
1470
+ typer.echo(f"OK: lowest scored precision/recall {worst:.2f} ≥ --fail-under {fail_under:.2f}")
1471
+
1472
+
1473
+ if __name__ == "__main__":
1474
+ app()