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
@@ -0,0 +1,62 @@
1
+ """The line range a route handler owns in its source file.
2
+
3
+ A handler's span runs from its route declaration down to just before the next
4
+ route in the same file (its own body), so one handler's decorator or body
5
+ never bleeds into a sibling's window. It is capped so a huge handler doesn't
6
+ swallow the file, and extended upward over contiguous ``@decorator`` lines for
7
+ the auth-decorator-above-``@route`` idiom.
8
+
9
+ Shared by the anomaly detector, authorization depth (#242) and webhook
10
+ verification (#243), which all need "is X present in *this* handler" rather
11
+ than "anywhere in the file".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ MAX_SPAN = 40
17
+ DECORATOR_LOOKUP = 6
18
+
19
+
20
+ def span_bounds(
21
+ line: int, lines: list[str], route_lines: list[int], *, max_span: int = MAX_SPAN
22
+ ) -> tuple[int, int]:
23
+ """Return the 0-based [start, end) slice of ``lines`` owned by the handler
24
+ declared at 1-based ``line``.
25
+
26
+ ``route_lines`` are the 1-based declaration lines of every route in the
27
+ same file (any order is fine). ``max_span`` caps the window; callers that
28
+ must see a whole handler body (authorization depth) pass a larger cap.
29
+ """
30
+ ordered = sorted(route_lines)
31
+ idx = line - 1 # 0-based route declaration line
32
+ # End at the next route declaration in the file (its decorators/body
33
+ # belong to it), capped so a huge handler can't swallow the file.
34
+ next_line = next((ln for ln in ordered if ln > line), None)
35
+ end = min(len(lines), line - 1 + max_span)
36
+ if next_line is not None:
37
+ end = min(end, next_line - 1)
38
+ # The decorators stacked on the next route belong to it, not to us.
39
+ while end - 1 > idx and lines[end - 1].lstrip().startswith("@"):
40
+ end -= 1
41
+ # Extend upward over contiguous decorator lines (auth decorator placed
42
+ # above the @route decorator), but not past the previous route.
43
+ prev_line = next((ln for ln in reversed(ordered) if ln < line), 0)
44
+ start = idx
45
+ limit = max(prev_line, idx - DECORATOR_LOOKUP)
46
+ while start - 1 >= limit and lines[start - 1].lstrip().startswith("@"):
47
+ start -= 1
48
+ return max(0, start), max(start + 1, end)
49
+
50
+
51
+ def span_text(
52
+ line: int | None, lines: list[str], route_lines: list[int], *, max_span: int = MAX_SPAN
53
+ ) -> str:
54
+ """The source text of the handler declared at ``line``; the whole file
55
+ when the route has no line."""
56
+ if line is None:
57
+ return "\n".join(lines)
58
+ start, end = span_bounds(line, lines, route_lines, max_span=max_span)
59
+ return "\n".join(lines[start:end])
60
+
61
+
62
+ __all__ = ["DECORATOR_LOOKUP", "MAX_SPAN", "span_bounds", "span_text"]
@@ -0,0 +1,505 @@
1
+ """Multi-pass hunt harness — slice 147a: N-skeptic majority-vote verify (#147).
2
+
3
+ The verifier is the asset the whole unknown-bug epic (#150) leans on. `--hunt`
4
+ generates candidate vulnerability hypotheses; a single verify pass adjudicates
5
+ them, and a single wrong "CONFIRMED" ships a false positive. This module turns
6
+ verification into a jury:
7
+
8
+ 1. **Generate** the hypotheses once (`hunt_generate` mode), which appends a
9
+ machine-readable `=== HYPOTHESES ===` list we parse into a fixed, id-keyed set.
10
+ 2. **N independent skeptics** (`hunt_skeptic` mode) each adjudicate that SAME
11
+ list against the real source — no skeptic sees another's verdict.
12
+ 3. **Combine by majority vote.** A lead is CONFIRMED only on a strict majority
13
+ of confirmations; NEEDS_REVIEW only when a strict majority flags the evidence
14
+ as insufficient; otherwise REFUTED. Ties, splits, missing votes, and
15
+ uncertainty never confirm — confirmation is the outcome that must clear a bar.
16
+
17
+ `combine_verdicts` is a pure function (unit-tested); the LLM calls are injected
18
+ so the orchestration is testable offline.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field, replace
26
+ from typing import Literal
27
+
28
+ from .review_prompts import HYPOTHESIS_MARKER, VERDICT_MARKER, _code_excerpts
29
+
30
+ Verdict = Literal["confirmed", "refuted", "needs_review"]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Hypothesis:
35
+ id: str
36
+ title: str
37
+ evidence: str = "" # the `[evidence: …]` ids cited by the generation pass
38
+ lenses: tuple[str, ...] = () # which failure-mode lens passes surfaced it (#147b)
39
+
40
+
41
+ @dataclass
42
+ class Consensus:
43
+ id: str
44
+ title: str
45
+ verdict: Verdict
46
+ confirmed: int = 0
47
+ refuted: int = 0
48
+ needs_review: int = 0
49
+ # (verdict, reason) per skeptic that gave one, so the report can quote a
50
+ # reason that agrees with the consensus verdict.
51
+ reasons: list[tuple[Verdict, str]] = field(default_factory=list)
52
+
53
+
54
+ # `H1: title [evidence: …]` lines in the generation pass's marker block.
55
+ _HYP_LINE_RE = re.compile(r"^\s*(H\d+)\s*[:.\-]\s*(.+?)\s*$", re.MULTILINE)
56
+ # `VERDICT H1: CONFIRMED — reason` lines from each skeptic pass. Tolerant of
57
+ # spacing, the id in brackets, and NEEDS REVIEW / NEEDS-REVIEW spelling.
58
+ # Anchored to the start of a line (#233): a VERDICT phrase quoted mid-line —
59
+ # e.g. from a repo comment echoed by the model — is not a vote.
60
+ _VERDICT_RE = re.compile(
61
+ r"^[ \t>*-]*VERDICT\s*\[?\s*(H\d+)\s*\]?\s*[:\-]\s*"
62
+ r"(CONFIRMED|REFUTED|NEEDS[ _\-]?REVIEW)\b"
63
+ r"[ \t]*(?:[—:\-][ \t]*(.*))?",
64
+ re.IGNORECASE | re.MULTILINE,
65
+ )
66
+
67
+ _VERDICT_NORMALIZE = {
68
+ "CONFIRMED": "confirmed",
69
+ "REFUTED": "refuted",
70
+ "NEEDS_REVIEW": "needs_review",
71
+ "NEEDS-REVIEW": "needs_review",
72
+ "NEEDS REVIEW": "needs_review",
73
+ }
74
+
75
+
76
+ def parse_hypotheses(markdown: str) -> list[Hypothesis]:
77
+ """Extract the fixed, ordered hypothesis list from a generation pass. Reads
78
+ only the text after the last ``=== HYPOTHESES ===`` marker (falls back to the
79
+ whole text if the marker is absent). Deduplicated by id, order preserved."""
80
+ idx = markdown.rfind(HYPOTHESIS_MARKER)
81
+ region = markdown[idx + len(HYPOTHESIS_MARKER):] if idx != -1 else markdown
82
+ out: list[Hypothesis] = []
83
+ seen: set[str] = set()
84
+ for match in _HYP_LINE_RE.finditer(region):
85
+ hid = match.group(1).upper()
86
+ if hid in seen:
87
+ continue
88
+ seen.add(hid)
89
+ raw = match.group(2).strip()
90
+ # Split the clean title from its "[evidence: …]" annotation, but KEEP
91
+ # the evidence ids — the skeptics need them to locate the cited chain.
92
+ ev_match = re.search(r"\[evidence:\s*(.*?)\]\s*$", raw, flags=re.IGNORECASE)
93
+ evidence = ev_match.group(1).strip() if ev_match else ""
94
+ title = re.sub(r"\s*\[evidence:.*?\]\s*$", "", raw, flags=re.IGNORECASE).strip()
95
+ out.append(Hypothesis(id=hid, title=title, evidence=evidence))
96
+ return out
97
+
98
+
99
+ # --- Cross-pass dedupe (#147b) --------------------------------------------
100
+
101
+ _STOPWORDS = frozenset({
102
+ "the", "a", "an", "of", "in", "on", "to", "and", "or", "via", "with", "for",
103
+ "at", "by", "from", "into", "that", "this", "is", "are", "reaches", "reach",
104
+ })
105
+
106
+
107
+ def _title_tokens(title: str) -> frozenset[str]:
108
+ words = re.findall(r"[A-Za-z0-9_./]+", title.lower())
109
+ return frozenset(w for w in words if len(w) > 2 and w not in _STOPWORDS)
110
+
111
+
112
+ def _evidence_ids(evidence: str) -> frozenset[str]:
113
+ return frozenset(t.strip() for t in re.split(r"[,\s]+", evidence.lower()) if ":" in t)
114
+
115
+
116
+ def _jaccard(a: frozenset, b: frozenset) -> float:
117
+ if not a and not b:
118
+ return 0.0
119
+ return len(a & b) / len(a | b)
120
+
121
+
122
+ def _same_lead(t_a, e_a, t_b, e_b) -> bool:
123
+ """Two hypotheses describe the same lead. Requires an identity signal — an
124
+ identical significant-token set, or shared cited-evidence ids plus similar
125
+ wording. Title similarity ALONE never merges: "… on /orders/{id}" and
126
+ "… on /users/{id}" are ~0.67 similar but are distinct leads on distinct
127
+ chains, so collapsing them would let a skeptic confirm one using the other's
128
+ evidence (#147b)."""
129
+ if t_a and t_a == t_b:
130
+ return True # identical significant tokens — same lead text
131
+ if (e_a & e_b) and _jaccard(t_a, t_b) >= 0.4:
132
+ return True # same cited chain + similar wording
133
+ return False
134
+
135
+
136
+ def dedupe_hypotheses(passes: list[list[Hypothesis]]) -> list[Hypothesis]:
137
+ """Merge hypotheses across passes (e.g. different lenses) into one fixed,
138
+ freshly re-numbered (H1..Hn) list. Restatements of the same lead collapse
139
+ into a single entry that unions their evidence ids and lens tags. Pure and
140
+ deterministic given the input order."""
141
+ kept: list[Hypothesis] = []
142
+ tokens: list[frozenset[str]] = []
143
+ evsets: list[frozenset[str]] = []
144
+ for pass_hyps in passes:
145
+ for h in pass_hyps:
146
+ t = _title_tokens(h.title)
147
+ e = _evidence_ids(h.evidence)
148
+ merged_into = None
149
+ for i in range(len(kept)):
150
+ if _same_lead(tokens[i], evsets[i], t, e):
151
+ merged_into = i
152
+ break
153
+ if merged_into is not None:
154
+ existing = kept[merged_into]
155
+ new_ev = sorted(evsets[merged_into] | e)
156
+ new_lenses = tuple(dict.fromkeys(existing.lenses + h.lenses))
157
+ kept[merged_into] = replace(
158
+ existing,
159
+ evidence=", ".join(new_ev) if new_ev else existing.evidence,
160
+ lenses=new_lenses,
161
+ )
162
+ evsets[merged_into] = evsets[merged_into] | e
163
+ else:
164
+ kept.append(h)
165
+ tokens.append(t)
166
+ evsets.append(e)
167
+ return [replace(h, id=f"H{i + 1}") for i, h in enumerate(kept)]
168
+
169
+
170
+ def parse_verdicts(
171
+ markdown: str, allowed_ids: set[str] | None = None
172
+ ) -> dict[str, tuple[Verdict, str]]:
173
+ """Parse ``VERDICT H<n>: <verdict> — <reason>`` lines from one skeptic pass.
174
+
175
+ Hardened against prompt injection (#233): when the output contains the
176
+ ``=== VERDICTS ===`` marker only the block after its last occurrence is
177
+ read; verdicts must start a line; and with ``allowed_ids`` any id that
178
+ isn't in the hypothesis list is ignored.
179
+ """
180
+ idx = markdown.rfind(VERDICT_MARKER)
181
+ region = markdown[idx + len(VERDICT_MARKER):] if idx != -1 else markdown
182
+ out: dict[str, tuple[Verdict, str]] = {}
183
+ for match in _VERDICT_RE.finditer(region):
184
+ hid = match.group(1).upper()
185
+ if allowed_ids is not None and hid not in allowed_ids:
186
+ continue
187
+ raw = re.sub(r"[ \-]", "_", match.group(2).upper())
188
+ verdict = _VERDICT_NORMALIZE.get(raw)
189
+ if verdict is None:
190
+ continue
191
+ reason = (match.group(3) or "").strip()
192
+ out[hid] = (verdict, reason) # last line for an id wins
193
+ return out
194
+
195
+
196
+ def _citation_tokens(hypothesis: Hypothesis, excerpt_keys: list[str]) -> set[str]:
197
+ tokens = {key.lower() for key in excerpt_keys}
198
+ tokens |= {key.rsplit(":", 1)[0].lower() for key in excerpt_keys} # bare file path
199
+ tokens |= {key.rsplit(":", 1)[0].rsplit("/", 1)[-1].lower() for key in excerpt_keys} # basename
200
+ tokens |= {t.strip().lower() for t in re.split(r"[,\s]+", hypothesis.evidence or "") if t.strip()}
201
+ return {t for t in tokens if t}
202
+
203
+
204
+ def enforce_citations(
205
+ verdicts: dict[str, tuple[Verdict, str]],
206
+ hypotheses: list[Hypothesis],
207
+ excerpt_keys: list[str],
208
+ ) -> dict[str, tuple[Verdict, str]]:
209
+ """Downgrade CONFIRMED/REFUTED votes that cite nothing to NEEDS_REVIEW (#233).
210
+
211
+ A skeptic that was steered by injected text (a planted note asking for a
212
+ refutation) typically can't point at code that supports its verdict. Only
213
+ enforced when excerpts were provided — without them there is nothing to
214
+ cite.
215
+ """
216
+ if not excerpt_keys:
217
+ return verdicts
218
+ by_id = {h.id: h for h in hypotheses}
219
+ out: dict[str, tuple[Verdict, str]] = {}
220
+ for hid, (verdict, reason) in verdicts.items():
221
+ hyp = by_id.get(hid)
222
+ if verdict != "needs_review" and hyp is not None:
223
+ lowered = reason.lower()
224
+ if not any(token in lowered for token in _citation_tokens(hyp, excerpt_keys)):
225
+ out[hid] = ("needs_review", f"uncited {verdict} verdict: {reason}".strip())
226
+ continue
227
+ out[hid] = (verdict, reason)
228
+ return out
229
+
230
+
231
+ def combine_verdicts(
232
+ hypotheses: list[Hypothesis],
233
+ per_pass: list[dict[str, tuple[Verdict, str]]],
234
+ ) -> list[Consensus]:
235
+ """Combine the skeptic passes into a per-hypothesis consensus:
236
+
237
+ - **CONFIRMED** only with a *strict majority* of CONFIRMED votes.
238
+ - **NEEDS_REVIEW** only with a *strict majority* explicitly flagging the
239
+ excerpt as insufficient (a genuine "a human should read this" signal).
240
+ - **REFUTED** for everything else — ties, splits, missing votes, and any
241
+ other uncertainty. Confirmation is the thing that must clear a majority
242
+ bar; when in doubt a lead is refuted, never confirmed.
243
+
244
+ Pure function: no I/O, deterministic given its inputs.
245
+ """
246
+ total = len(per_pass)
247
+ results: list[Consensus] = []
248
+ for hyp in hypotheses:
249
+ confirmed = refuted = needs = 0
250
+ reasons: list[tuple[Verdict, str]] = []
251
+ for votes in per_pass:
252
+ entry = votes.get(hyp.id)
253
+ if entry is None:
254
+ refuted += 1 # a skeptic that didn't rule on it does not confirm it
255
+ continue
256
+ verdict, reason = entry
257
+ if verdict == "confirmed":
258
+ confirmed += 1
259
+ elif verdict == "needs_review":
260
+ needs += 1
261
+ else:
262
+ refuted += 1
263
+ if reason:
264
+ reasons.append((verdict, reason))
265
+ if total and confirmed * 2 > total:
266
+ final: Verdict = "confirmed"
267
+ elif total and needs * 2 > total:
268
+ final = "needs_review"
269
+ else:
270
+ final = "refuted"
271
+ results.append(
272
+ Consensus(
273
+ id=hyp.id,
274
+ title=hyp.title,
275
+ verdict=final,
276
+ confirmed=confirmed,
277
+ refuted=refuted,
278
+ needs_review=needs,
279
+ reasons=reasons,
280
+ )
281
+ )
282
+ return results
283
+
284
+
285
+ _VERDICT_ORDER = {"confirmed": 0, "needs_review": 1, "refuted": 2}
286
+ _VERDICT_HEADING = {
287
+ "confirmed": "Confirmed",
288
+ "needs_review": "Needs human review",
289
+ "refuted": "Refuted",
290
+ }
291
+
292
+
293
+ def render_consensus_report(consensus: list[Consensus], votes: int) -> str:
294
+ """Render the adjudicated shortlist, grouped by consensus verdict."""
295
+ lines = [
296
+ "# AttackMap hunt — verified hypotheses",
297
+ "",
298
+ f"_Majority vote of {votes} independent skeptic pass(es). A lead is "
299
+ "CONFIRMED only on a strict majority; NEEDS_REVIEW only when a majority "
300
+ "flags the evidence as insufficient; otherwise REFUTED — ties and "
301
+ "uncertainty never confirm._",
302
+ "",
303
+ ]
304
+ ordered = sorted(consensus, key=lambda c: (_VERDICT_ORDER[c.verdict], c.id))
305
+ for verdict in ("confirmed", "needs_review", "refuted"):
306
+ group = [c for c in ordered if c.verdict == verdict]
307
+ if not group:
308
+ continue
309
+ lines.append(f"## {_VERDICT_HEADING[verdict]} ({len(group)})")
310
+ lines.append("")
311
+ for c in group:
312
+ tally = f"{c.confirmed}/{votes} confirmed"
313
+ if c.needs_review:
314
+ tally += f", {c.needs_review} needs-review"
315
+ lines.append(f"- `{c.id}` — **{c.title}** ({tally})")
316
+ reason = _reason_for(c)
317
+ if reason:
318
+ lines.append(f" - {reason}")
319
+ lines.append("")
320
+ return "\n".join(lines)
321
+
322
+
323
+ def _reason_for(c: Consensus) -> str:
324
+ """Quote a skeptic reason that AGREES with the consensus verdict, so a
325
+ Confirmed item never shows a dissenting `[refuted]` justification."""
326
+ for verdict, reason in c.reasons:
327
+ if verdict == c.verdict:
328
+ return reason
329
+ return c.reasons[0][1] if c.reasons else ""
330
+
331
+
332
+ @dataclass
333
+ class MajorityVerifyResult:
334
+ report: str
335
+ hypothesis_count: int
336
+ votes: int
337
+ consensus: list[Consensus]
338
+ backend: str
339
+ model: str
340
+ rounds: int = 1
341
+ usage: dict[str, int] = field(default_factory=dict)
342
+
343
+
344
+ def _add_usage(total: dict[str, int], usage: dict | None) -> None:
345
+ for key, value in (usage or {}).items():
346
+ if isinstance(value, int):
347
+ total[key] = total.get(key, 0) + value
348
+
349
+
350
+ def _account(total: dict[str, int], result) -> None:
351
+ """Accumulate a call's usage, keeping ``output_tokens`` a best-effort count
352
+ for the budget guardrail. Backends that don't report it (e.g. the Codex
353
+ CLI) get an estimate from the response length (~4 chars/token) so
354
+ ``--hunt-budget`` still bounds them."""
355
+ usage = getattr(result, "usage", None) or {}
356
+ _add_usage(total, usage)
357
+ if not usage.get("output_tokens"):
358
+ total["output_tokens"] = total.get("output_tokens", 0) + max(1, len(result.markdown) // 4)
359
+
360
+
361
+ def _output_spent(usage: dict[str, int]) -> int:
362
+ """Output tokens spent so far — the unit `--hunt-budget` is measured in."""
363
+ return usage.get("output_tokens", 0)
364
+
365
+
366
+ def _generate_round(
367
+ llm_call: Callable,
368
+ lenses: list[str] | None,
369
+ avoid_titles: list[str] | None,
370
+ critic_hint: str | None,
371
+ usage_total: dict[str, int],
372
+ ) -> tuple[list[list[Hypothesis]], str, str, str]:
373
+ """One generation round. With multiple lenses, one specialised pass each
374
+ (#147b); otherwise a single generalist pass. Returns
375
+ ``(passes, backend, model, last_markdown)``."""
376
+ passes: list[list[Hypothesis]] = []
377
+ backend = model = last_markdown = ""
378
+ lens_list = lenses if (lenses and len(lenses) > 1) else [None]
379
+ for lens in lens_list:
380
+ # Pass optional priming only when set, so simple injected llm_calls
381
+ # (single-round / multi-lens paths) don't need those kwargs.
382
+ kwargs: dict = {}
383
+ if lens is not None:
384
+ kwargs["lens"] = lens
385
+ if avoid_titles:
386
+ kwargs["avoid_titles"] = avoid_titles
387
+ if critic_hint:
388
+ kwargs["critic_hint"] = critic_hint
389
+ g = llm_call("hunt_generate", **kwargs)
390
+ _account(usage_total, g)
391
+ backend, model, last_markdown = g.backend, g.model, g.markdown
392
+ parsed = parse_hypotheses(g.markdown)
393
+ if lens is not None:
394
+ parsed = [replace(h, lenses=(lens,)) for h in parsed]
395
+ passes.append(parsed)
396
+ return passes, backend, model, last_markdown
397
+
398
+
399
+ def run_majority_verify(
400
+ scan,
401
+ attack_surfaces,
402
+ findings,
403
+ attack_paths,
404
+ *,
405
+ votes: int,
406
+ llm_call: Callable,
407
+ lenses: list[str] | None = None,
408
+ max_rounds: int = 1,
409
+ dry_streak: int = 1,
410
+ token_budget: int | None = None,
411
+ ) -> MajorityVerifyResult:
412
+ """Generate hypotheses, then adjudicate them with ``votes`` independent
413
+ skeptics and combine by majority vote.
414
+
415
+ ``llm_call(mode, hypotheses=None, lens=None, avoid_titles=None,
416
+ critic_hint=None)`` runs one LLM pass and returns an ``LlmReviewResult`` —
417
+ injected so the orchestration is testable offline. With ``lenses`` (>1),
418
+ generation fans out one specialised pass per lens and dedupes (#147b). With
419
+ ``max_rounds`` > 1 (#147c), generation LOOPS: each round accumulates the new
420
+ (deduped) leads and a completeness critic seeds the next round's untried
421
+ angles; it stops after ``dry_streak`` consecutive rounds add nothing new, the
422
+ round cap, or the ``token_budget`` (output tokens) — whichever comes first.
423
+ """
424
+ usage_total: dict[str, int] = {}
425
+ gen_backend = gen_model = ""
426
+ last_markdown = ""
427
+ rounds_run = 0
428
+
429
+ if max_rounds > 1:
430
+ accumulated: list[Hypothesis] = []
431
+ dry = 0
432
+ critic_hint: str | None = None
433
+ for _ in range(max_rounds):
434
+ if token_budget and _output_spent(usage_total) >= token_budget:
435
+ break
436
+ rounds_run += 1
437
+ avoid = [h.title for h in accumulated] or None
438
+ passes, gb, gm, last_markdown = _generate_round(
439
+ llm_call, lenses, avoid, critic_hint, usage_total
440
+ )
441
+ gen_backend, gen_model = gb or gen_backend, gm or gen_model
442
+ before = len(accumulated)
443
+ accumulated = dedupe_hypotheses([accumulated, *passes] if accumulated else passes)
444
+ if len(accumulated) <= before:
445
+ dry += 1
446
+ if dry >= dry_streak:
447
+ break
448
+ continue
449
+ dry = 0
450
+ # Seed the next round unless we're done or out of budget.
451
+ more_rounds_left = rounds_run < max_rounds
452
+ budget_left = not (token_budget and _output_spent(usage_total) >= token_budget)
453
+ if more_rounds_left and budget_left and accumulated:
454
+ crit = llm_call(
455
+ "hunt_critic",
456
+ hypotheses=[{"id": h.id, "title": h.title, "evidence": h.evidence} for h in accumulated],
457
+ )
458
+ _account(usage_total, crit)
459
+ critic_hint = crit.markdown
460
+ hypotheses = accumulated
461
+ elif lenses and len(lenses) > 1:
462
+ rounds_run = 1
463
+ passes, gen_backend, gen_model, last_markdown = _generate_round(
464
+ llm_call, lenses, None, None, usage_total
465
+ )
466
+ hypotheses = dedupe_hypotheses(passes)
467
+ else:
468
+ rounds_run = 1
469
+ passes, gen_backend, gen_model, last_markdown = _generate_round(
470
+ llm_call, None, None, None, usage_total
471
+ )
472
+ hypotheses = passes[0] if passes else []
473
+
474
+ if not hypotheses:
475
+ # Nothing parseable to vote on — hand back the generation output as-is.
476
+ report = last_markdown if last_markdown.strip() else "_No hypotheses were surfaced._\n"
477
+ return MajorityVerifyResult(
478
+ report=report, hypothesis_count=0, votes=votes, rounds=rounds_run,
479
+ consensus=[], backend=gen_backend, model=gen_model, usage=usage_total,
480
+ )
481
+
482
+ hyp_dicts = [{"id": h.id, "title": h.title, "evidence": h.evidence} for h in hypotheses]
483
+ allowed_ids = {h.id for h in hypotheses}
484
+ # The same excerpts the skeptic prompt carries; verdicts must cite one.
485
+ excerpt_keys = list(_code_excerpts(scan, findings)) if scan is not None else []
486
+ per_pass: list[dict[str, tuple[Verdict, str]]] = []
487
+ for _ in range(votes):
488
+ sk = llm_call("hunt_skeptic", hypotheses=hyp_dicts)
489
+ _account(usage_total, sk)
490
+ per_pass.append(
491
+ enforce_citations(parse_verdicts(sk.markdown, allowed_ids), hypotheses, excerpt_keys)
492
+ )
493
+
494
+ consensus = combine_verdicts(hypotheses, per_pass)
495
+ report = render_consensus_report(consensus, votes)
496
+ return MajorityVerifyResult(
497
+ report=report,
498
+ hypothesis_count=len(hypotheses),
499
+ votes=votes,
500
+ rounds=rounds_run,
501
+ consensus=consensus,
502
+ backend=gen_backend,
503
+ model=gen_model,
504
+ usage=usage_total,
505
+ )