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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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()
|