Skip to content

Commit bfe486f

Browse files
committed
AI triage guardrails: a per scan cap and a reuse cache, so tokens stay cheap
The model is now a bounded advisor, not the scanner. Three limits, in code: It is only ever sent findings that passed the deterministic filters, which was already true and is the reason it never reads raw traffic. A finding unchanged since the site's last reviewed scan reuses the verdict it already got, by a fingerprint of what the model would read, so a re-scan that turns up the same findings sends nothing and is billed nothing. Of the rest, only the cap goes to the model, highest risk first; the overflow keeps its computed risk and is marked not reviewed. The cap is a setting, dast.ai_max_findings, default forty, so a noisy scan cannot run up a bill. The review summary now reports how many were reused and how many were over the cap. A pass that only reused verdicts makes no call and is a success.
1 parent 7de13ac commit bfe486f

2 files changed

Lines changed: 69 additions & 4 deletions

File tree

‎app/core/external.py‎

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3545,9 +3545,46 @@ def _spent(cfg, answers: list) -> dict:
35453545
return total
35463546

35473547

3548+
def _evfp(f: dict) -> str:
3549+
"""A fingerprint of what the model would read, so an unchanged finding can
3550+
reuse the verdict it already got instead of being sent again."""
3551+
import hashlib
3552+
3553+
basis = f"{f.get('evidence') or ''}|{f.get('severity') or ''}|{f.get('title') or ''}"
3554+
return hashlib.md5(basis.encode("utf-8", "replace")).hexdigest()[:12]
3555+
3556+
3557+
def _prior_verdicts(db, asset_id: int, scan_id: int) -> dict:
3558+
"""The AI verdicts from this site's last reviewed scan, by finding key, so a
3559+
finding that has not changed reuses its verdict and is not sent again."""
3560+
from app.core.external_decisions import finding_key
3561+
3562+
prev = (db.query(ExternalDastScan)
3563+
.filter(ExternalDastScan.asset_id == asset_id,
3564+
ExternalDastScan.id != scan_id,
3565+
ExternalDastScan.ai_status == "done")
3566+
.order_by(ExternalDastScan.id.desc()).first())
3567+
if prev is None:
3568+
return {}
3569+
out = {}
3570+
for f in findings_of(prev):
3571+
if f.get("ai_verdict"):
3572+
out[f.get("key") or finding_key(f)] = {
3573+
"verdict": f["ai_verdict"], "why": f.get("ai_why", ""),
3574+
"sast": f.get("ai_sast", ""), "evfp": _evfp(f)}
3575+
return out
3576+
3577+
35483578
def triage(scan_id: int, actor: str = "") -> None:
3549-
"""Ask the model to sort one scan's findings. Runs in the caller's thread."""
3579+
"""Ask the model to sort one scan's findings. Runs in the caller's thread.
3580+
3581+
Guardrails so the model is a bounded advisor, not the scanner: it is only
3582+
ever sent findings that passed the deterministic filters; a finding
3583+
unchanged since the last scan reuses its verdict without a call; and no more
3584+
than the cap go to the model, highest risk first, so a noisy scan cannot run
3585+
up a bill."""
35503586
from app.core import ai
3587+
from app.core import settings_store as settings_mod
35513588

35523589
with session_scope() as db:
35533590
cfg = ai.config(db)
@@ -3586,10 +3623,28 @@ def triage(scan_id: int, actor: str = "") -> None:
35863623
if links:
35873624
sast = _sast_lines(sast_findings(db, [l["full_name"] for l in links], 60, who))
35883625
host = asset.host
3626+
cap = max(1, int(settings_mod.get(db, "dast.ai_max_findings") or 40))
3627+
prior = _prior_verdicts(db, scan.asset_id, scan_id)
3628+
# Reuse a verdict for any finding unchanged since the last reviewed scan.
3629+
reused = 0
3630+
for f in findings:
3631+
pv = prior.get(f["key"])
3632+
if pv and pv["evfp"] == _evfp(f):
3633+
f["ai_verdict"], f["ai_why"], f["ai_sast"] = pv["verdict"], pv["why"], pv["sast"]
3634+
f["ai_cached"] = True
3635+
reused += 1
3636+
# Of the rest, send only the cap, highest severity first; the overflow keeps
3637+
# its computed risk and is marked not reviewed.
3638+
to_send = sorted((f for f in findings if not f.get("ai_cached")),
3639+
key=lambda f: RANK.get(f.get("severity"), 0), reverse=True)
3640+
capped = len(to_send) - cap
3641+
for f in to_send[cap:]:
3642+
f["ai_note"] = "not reviewed by the model: over the per scan cap"
3643+
to_send = to_send[:cap]
35893644
answers, verdicts, summaries, priorities, unreadable = [], {}, [], [], 0
35903645
chunk = 120
3591-
for start in range(0, max(1, len(findings)), chunk):
3592-
part = findings[start:start + chunk]
3646+
for start in range(0, max(1, len(to_send)), chunk):
3647+
part = to_send[start:start + chunk]
35933648
if not part:
35943649
break
35953650
prompt = json.dumps({
@@ -3619,7 +3674,10 @@ def triage(scan_id: int, actor: str = "") -> None:
36193674
if isinstance(v, dict) and v.get("id"):
36203675
verdicts[str(v["id"])] = v
36213676
spent = _spent(cfg, answers)
3622-
ok = any(a["ok"] for a in answers) and (summaries or verdicts)
3677+
# A pass that only reused verdicts made no call, and that is a success, not
3678+
# a failure.
3679+
ok = (any(a["ok"] for a in answers) and (summaries or verdicts)) or (
3680+
not answers and reused > 0)
36233681
for f in all_findings:
36243682
v = verdicts.get(str(f.get("id") or f["key"])) or verdicts.get(f["key"])
36253683
if v:
@@ -3642,6 +3700,7 @@ def triage(scan_id: int, actor: str = "") -> None:
36423700
scan.ai_summary = json.dumps({
36433701
"removed": removed, "tidied": (before or {}).get("tidied") if isinstance(before, dict) else None,
36443702
"summary": " ".join(summaries)[:4000], "priorities": priorities[:8],
3703+
"reused": reused, "capped": max(0, capped),
36453704
"model": next((a.get("model") for a in answers if a.get("model")), ""),
36463705
"error": "" if ok else next(
36473706
(a["error"] for a in answers if a.get("error")),

‎app/core/settings_store.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,12 @@
408408
"Squid address such as http://proxy.host:3128, or "
409409
"http://user:password@proxy.host:3128 when it needs a "
410410
"sign in. Held encrypted."),
411+
"dast.ai_max_findings": (40, False,
412+
"The most findings one scan sends to the model for review. "
413+
"The rest keep their computed risk and are marked not "
414+
"reviewed, so a noisy scan cannot run up a large bill. The "
415+
"model is only ever sent findings that passed the "
416+
"deterministic filters, highest risk first."),
411417
"dast.ip_echo_url": ("https://api.ipify.org", False,
412418
"A service that answers with the caller's IP, used by "
413419
"Validate proxy to show the address the proxy leaves "

0 commit comments

Comments
 (0)