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
|
@@ -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
|
+
)
|