|
| 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 |
0 commit comments