Skip to content

Commit dd1761a

Browse files
committed
feat(audit): add deterministic curation and PoC report finalization
1 parent 21eb84a commit dd1761a

15 files changed

Lines changed: 2200 additions & 155 deletions

agentflow/audit/curation.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import Literal
5+
import re
6+
7+
from pydantic import BaseModel, ConfigDict, Field
8+
9+
from agentflow.audit.models import FindingRecord
10+
11+
_NO_CHANGE_PATIENCE = 3
12+
_TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
13+
_STOPWORDS = {
14+
"the",
15+
"and",
16+
"for",
17+
"with",
18+
"that",
19+
"this",
20+
"from",
21+
"into",
22+
"when",
23+
"then",
24+
"than",
25+
"over",
26+
"under",
27+
"after",
28+
"before",
29+
"same",
30+
"does",
31+
"doesn",
32+
"without",
33+
"through",
34+
"across",
35+
"their",
36+
"have",
37+
"has",
38+
"will",
39+
"only",
40+
"more",
41+
"less",
42+
"than",
43+
"risk",
44+
"issue",
45+
"finding",
46+
}
47+
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
48+
49+
50+
class CurationDecision(BaseModel):
51+
model_config = ConfigDict(extra="forbid")
52+
53+
status: Literal["CONTINUE", "STOP"]
54+
round: int
55+
consecutive_no_change: int
56+
changed: bool
57+
previous_count: int
58+
current_count: int
59+
60+
61+
class CurationState(BaseModel):
62+
model_config = ConfigDict(extra="forbid")
63+
64+
round: int = 0
65+
consecutive_no_change: int = 0
66+
curated_findings: list[FindingRecord] = Field(default_factory=list)
67+
history: list[CurationDecision] = Field(default_factory=list)
68+
69+
70+
def curated_findings_for_output(findings: list[FindingRecord]) -> list[FindingRecord]:
71+
return sorted(
72+
[
73+
finding
74+
for finding in findings
75+
if finding.validation_status != "rejected" and finding.review.disposition != "merged"
76+
],
77+
key=lambda finding: (finding.dedup_fingerprint, finding.id),
78+
)
79+
80+
81+
def _tokenize(text: str) -> set[str]:
82+
tokens = {
83+
token
84+
for token in _TOKEN_PATTERN.findall(text.lower())
85+
if len(token) >= 4 and token not in _STOPWORDS
86+
}
87+
return tokens
88+
89+
90+
def _fingerprint_family(fingerprint: str) -> tuple[str, ...]:
91+
normalized = fingerprint.lower().replace("/", "|").replace(":", "|").replace("-", "|")
92+
parts = [part for part in normalized.split("|") if part and not part.endswith(".sol")]
93+
return tuple(parts[:4])
94+
95+
96+
def _finding_tokens(finding: FindingRecord) -> set[str]:
97+
evidence_files = " ".join(item.file for item in finding.evidence)
98+
return _tokenize(
99+
" ".join(
100+
[
101+
finding.category,
102+
finding.title,
103+
finding.summary,
104+
finding.root_cause,
105+
finding.attack_scenario,
106+
finding.component.file,
107+
evidence_files,
108+
]
109+
)
110+
)
111+
112+
113+
def _should_merge(left: FindingRecord, right: FindingRecord) -> bool:
114+
if left.validation_status != right.validation_status:
115+
return False
116+
left_family = _fingerprint_family(left.dedup_fingerprint)
117+
right_family = _fingerprint_family(right.dedup_fingerprint)
118+
if left_family and right_family and left_family == right_family:
119+
return True
120+
121+
if left.category != right.category:
122+
return False
123+
124+
left_tokens = _finding_tokens(left)
125+
right_tokens = _finding_tokens(right)
126+
if not left_tokens or not right_tokens:
127+
return False
128+
overlap = len(left_tokens & right_tokens)
129+
smallest = min(len(left_tokens), len(right_tokens))
130+
return overlap >= 4 and overlap / smallest >= 0.55
131+
132+
133+
def _merge_findings(primary: FindingRecord, secondary: FindingRecord) -> FindingRecord:
134+
severity = primary.severity
135+
if _SEVERITY_RANK[secondary.severity] < _SEVERITY_RANK[severity]:
136+
severity = secondary.severity
137+
138+
evidence_by_key: dict[tuple[str, int, int, str], object] = {}
139+
for item in [*primary.evidence, *secondary.evidence]:
140+
key = (item.file, item.start_line, item.end_line, item.snippet_ref)
141+
evidence_by_key[key] = item
142+
143+
notes = primary.review.notes.strip()
144+
merged_note = f"Merged related variant {secondary.id} into {primary.id}."
145+
if notes:
146+
notes = f"{notes} {merged_note}"
147+
else:
148+
notes = merged_note
149+
150+
return primary.model_copy(
151+
update={
152+
"severity": severity,
153+
"evidence": list(
154+
sorted(
155+
evidence_by_key.values(),
156+
key=lambda item: (item.file, item.start_line, item.end_line, item.snippet_ref),
157+
)
158+
),
159+
"review": primary.review.model_copy(update={"notes": notes}),
160+
}
161+
)
162+
163+
164+
def curate_findings(findings: list[FindingRecord]) -> list[FindingRecord]:
165+
curated: list[FindingRecord] = []
166+
for finding in sorted(
167+
curated_findings_for_output(findings),
168+
key=lambda item: (_SEVERITY_RANK[item.severity], item.id),
169+
):
170+
merged = False
171+
for index, existing in enumerate(curated):
172+
if _should_merge(existing, finding):
173+
curated[index] = _merge_findings(existing, finding)
174+
merged = True
175+
break
176+
if not merged:
177+
curated.append(finding)
178+
deduped_ids: list[FindingRecord] = []
179+
seen_ids: dict[str, int] = {}
180+
for finding in curated_findings_for_output(curated):
181+
count = seen_ids.get(finding.id, 0) + 1
182+
seen_ids[finding.id] = count
183+
if count == 1:
184+
deduped_ids.append(finding)
185+
continue
186+
deduped_ids.append(
187+
finding.model_copy(update={"id": f"{finding.id}-{count}"})
188+
)
189+
return deduped_ids
190+
191+
192+
def _comparison_payload(findings: list[FindingRecord]) -> list[dict[str, object]]:
193+
return [finding.model_dump(mode="json") for finding in curated_findings_for_output(findings)]
194+
195+
196+
def load_curation_state(path: str | Path) -> CurationState:
197+
state_path = Path(path)
198+
if not state_path.exists():
199+
return CurationState()
200+
return CurationState.model_validate_json(state_path.read_text(encoding="utf-8"))
201+
202+
203+
def write_curation_state(path: str | Path, state: CurationState) -> None:
204+
state_path = Path(path)
205+
state_path.parent.mkdir(parents=True, exist_ok=True)
206+
state_path.write_text(state.model_dump_json(indent=2), encoding="utf-8")
207+
208+
209+
def current_curated_findings(state: CurationState, seed_findings: list[FindingRecord]) -> list[FindingRecord]:
210+
if state.curated_findings:
211+
return curated_findings_for_output(state.curated_findings)
212+
return curated_findings_for_output(seed_findings)
213+
214+
215+
def curation_input_payload(findings: list[FindingRecord]) -> list[dict[str, object]]:
216+
payload: list[dict[str, object]] = []
217+
for finding in curated_findings_for_output(findings):
218+
payload.append(
219+
{
220+
"id": finding.id,
221+
"title": finding.title,
222+
"severity": finding.severity,
223+
"category": finding.category,
224+
"validation_status": finding.validation_status,
225+
"component": finding.component.model_dump(mode="json"),
226+
"summary": finding.summary,
227+
"root_cause": finding.root_cause,
228+
"attack_scenario": finding.attack_scenario,
229+
"dedup_fingerprint": finding.dedup_fingerprint,
230+
"evidence_refs": [
231+
f"{item.file}:{item.start_line}-{item.end_line}"
232+
for item in finding.evidence
233+
],
234+
}
235+
)
236+
return payload
237+
238+
239+
def curation_prompt_payload(
240+
state: CurationState,
241+
seed_findings: list[FindingRecord],
242+
) -> dict[str, object]:
243+
current_findings = current_curated_findings(state, seed_findings)
244+
return {
245+
"round": state.round,
246+
"consecutive_no_change": state.consecutive_no_change,
247+
"current_findings": curation_input_payload(current_findings),
248+
"recent_history": [entry.model_dump(mode="json") for entry in state.history[-5:]],
249+
}
250+
251+
252+
def apply_curation_round(
253+
state: CurationState,
254+
findings: list[FindingRecord],
255+
*,
256+
no_change_patience: int = _NO_CHANGE_PATIENCE,
257+
) -> tuple[CurationState, CurationDecision]:
258+
next_findings = curated_findings_for_output(findings)
259+
changed = _comparison_payload(next_findings) != _comparison_payload(state.curated_findings)
260+
consecutive_no_change = state.consecutive_no_change + 1
261+
if changed:
262+
consecutive_no_change = 0
263+
decision = CurationDecision(
264+
status="STOP" if consecutive_no_change >= no_change_patience else "CONTINUE",
265+
round=state.round + 1,
266+
consecutive_no_change=consecutive_no_change,
267+
changed=changed,
268+
previous_count=len(curated_findings_for_output(state.curated_findings)),
269+
current_count=len(next_findings),
270+
)
271+
next_state = CurationState(
272+
round=decision.round,
273+
consecutive_no_change=consecutive_no_change,
274+
curated_findings=next_findings,
275+
history=[*state.history, decision],
276+
)
277+
return next_state, decision
278+
279+
280+
def advance_curation_state(
281+
path: str | Path,
282+
findings: list[FindingRecord],
283+
*,
284+
no_change_patience: int = _NO_CHANGE_PATIENCE,
285+
) -> tuple[CurationState, CurationDecision]:
286+
state = load_curation_state(path)
287+
next_state, decision = apply_curation_round(
288+
state,
289+
findings,
290+
no_change_patience=no_change_patience,
291+
)
292+
write_curation_state(path, next_state)
293+
return next_state, decision

agentflow/audit/discovery.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,27 @@ def advance_discovery_state(
147147
return next_state, decision
148148

149149

150+
def apply_discovery_round_until_stop(
151+
state: DiscoveryState,
152+
findings: list[FindingRecord],
153+
*,
154+
no_progress_patience: int = _NO_PROGRESS_PATIENCE,
155+
max_rounds: int = 2,
156+
) -> tuple[DiscoveryState, list[DiscoveryDecision]]:
157+
current_state = state
158+
decisions: list[DiscoveryDecision] = []
159+
for _ in range(max_rounds):
160+
current_state, decision = apply_discovery_round(
161+
current_state,
162+
findings,
163+
no_progress_patience=no_progress_patience,
164+
)
165+
decisions.append(decision)
166+
if decision.status == "STOP":
167+
break
168+
return current_state, decisions
169+
170+
150171
def customer_visible_findings(state: DiscoveryState) -> list[FindingRecord]:
151172
visible = [
152173
finding

0 commit comments

Comments
 (0)