Skip to content

Commit ed01a9d

Browse files
committed
DAST reports leave out code findings with an exception or a false positive
A scan's AI triage and a site's AI risk read keep what the model wrote, so a code finding given an exception afterward went on appearing in the PDF. Saved reviews are now checked each time they are shown. A summary that names a code finding since excepted, out of scope or graded info is hidden with a note to run it again, and priorities, next steps, matches and correlations that name one are dropped. The code finding counts and the list sent to the model also filter on excepted, and both prompts ask the model to cite finding ids.
1 parent f55ad62 commit ed01a9d

7 files changed

Lines changed: 126 additions & 6 deletions

File tree

‎app/core/external.py‎

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,7 @@ def sast_counts(db, names, user=None) -> dict:
946946
Finding.repository, Finding.severity, func.count(Finding.id))
947947
.filter(Finding.repository.in_(names))
948948
.filter(Finding.state == FindingState.OPEN)
949+
.filter(Finding.excepted.is_(False))
949950
.filter(Finding.out_of_scope.is_(False))
950951
.group_by(Finding.repository, Finding.severity).all()):
951952
out.setdefault(repo, {})[getattr(severity, "value", str(severity))] = total
@@ -960,12 +961,99 @@ def sast_findings(db, names, limit: int = 80, user=None) -> list:
960961
Severity.LOW: 3}
961962
rows = (_findings(db, user).filter(Finding.repository.in_(list(names) or [""]))
962963
.filter(Finding.state == FindingState.OPEN)
964+
.filter(Finding.excepted.is_(False))
963965
.filter(Finding.out_of_scope.is_(False))
964966
.filter(Finding.severity.in_(list(order))).limit(2000).all())
965967
rows.sort(key=lambda f: (order.get(f.severity, 9), -(f.risk_score or 0)))
966968
return rows[:limit]
967969

968970

971+
# How a code finding is named everywhere: GCR- and twelve hex digits.
972+
_VULN_ID = re.compile(r"\bGCR-[0-9A-F]{12}\b")
973+
974+
975+
def settled_sast(db, ids) -> set:
976+
"""The code findings among these ids a person took out of the counts.
977+
978+
An exception (an approved waiver or an exclusion rule sets excepted), a
979+
not deployed scope, or a false positive, which is a regrade to info. A
980+
fixed finding is not here: it was real, and saying so is still true.
981+
"""
982+
from app.core.models import Severity
983+
984+
ids = sorted({i for i in ids if i})
985+
if not ids:
986+
return set()
987+
out = set()
988+
for start in range(0, len(ids), 500):
989+
for vuln_id, excepted, out_of_scope, state, severity in (
990+
db.query(Finding.vuln_id, Finding.excepted, Finding.out_of_scope,
991+
Finding.state, Finding.severity)
992+
.filter(Finding.vuln_id.in_(ids[start:start + 500])).all()):
993+
if (excepted or out_of_scope or state == FindingState.WAIVED
994+
or severity == Severity.INFO):
995+
out.add(vuln_id)
996+
return out
997+
998+
999+
def _cites(text, settled) -> bool:
1000+
return any(i in settled for i in _VULN_ID.findall(str(text or "")))
1001+
1002+
1003+
def _drop_sentences(text, settled) -> str:
1004+
parts = re.split(r"(?<=[.!?])\s+", str(text or ""))
1005+
return " ".join(p for p in parts if not _cites(p, settled)).strip()
1006+
1007+
1008+
def scrub_review(db, review: dict, findings: list = None,
1009+
text: str = "summary", lists=("priorities", "next_steps")) -> dict:
1010+
"""A saved model answer, less what it said about settled code findings.
1011+
1012+
The answer is kept as it was written, so a finding somebody later called
1013+
a false positive or gave an exception would go on being reported. This
1014+
is applied every time one is shown. The summary goes whole when it names
1015+
one, since the sentences around the id are about the same code. Returns
1016+
a copy, with "withheld" set to how many settled findings it named.
1017+
"""
1018+
review = dict(review or {})
1019+
findings = findings if findings is not None else []
1020+
seen = set()
1021+
for value in [review.get(text)] + [x for k in lists for x in review.get(k) or []]:
1022+
seen.update(_VULN_ID.findall(str(value or "")))
1023+
for c in review.get("correlations") or []:
1024+
if isinstance(c, dict):
1025+
seen.update(_VULN_ID.findall(str(c.get("sast") or "")))
1026+
for f in findings:
1027+
seen.update(_VULN_ID.findall(str(f.get("ai_sast") or "") + " " + str(f.get("ai_why") or "")))
1028+
settled = settled_sast(db, seen)
1029+
review["withheld"] = 0
1030+
if not settled:
1031+
return review
1032+
named = set()
1033+
for value in [review.get(text)] + [x for k in lists for x in review.get(k) or []]:
1034+
named.update(i for i in _VULN_ID.findall(str(value or "")) if i in settled)
1035+
if _cites(review.get(text), settled):
1036+
review[text] = ""
1037+
for k in lists:
1038+
if review.get(k):
1039+
review[k] = [x for x in review[k] if not _cites(x, settled)]
1040+
if review.get("correlations"):
1041+
kept = []
1042+
for c in review["correlations"]:
1043+
if isinstance(c, dict) and _cites(c.get("sast"), settled):
1044+
named.update(i for i in _VULN_ID.findall(str(c.get("sast"))) if i in settled)
1045+
continue
1046+
kept.append(c)
1047+
review["correlations"] = kept
1048+
for f in findings:
1049+
if _cites(f.get("ai_sast"), settled):
1050+
f["ai_sast"] = ""
1051+
if _cites(f.get("ai_why"), settled):
1052+
f["ai_why"] = _drop_sentences(f["ai_why"], settled)
1053+
review["withheld"] = len(named)
1054+
return review
1055+
1056+
9691057
RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0, "none": 0}
9701058

9711059

@@ -1325,6 +1413,11 @@ def scan_dict(scan: ExternalDastScan, findings: bool = False) -> dict:
13251413
out["ai"]["summary"] = json.loads(scan.ai_summary or "{}")
13261414
except ValueError:
13271415
out["ai"]["summary"] = {}
1416+
from sqlalchemy.orm import object_session
1417+
1418+
db = object_session(scan)
1419+
if db is not None and isinstance(out["ai"]["summary"], dict):
1420+
out["ai"]["summary"] = scrub_review(db, out["ai"]["summary"], out["findings"])
13281421
return out
13291422

13301423

@@ -2671,7 +2764,9 @@ def recover() -> int:
26712764
{"summary": "three to five sentences for an engineer, plain US English",
26722765
"verdicts": [{"id": "finding id", "verdict": "likely_real", "why": "one sentence",
26732766
"sast": "matching SAST finding id or empty"}],
2674-
"priorities": ["the three things to fix first, one sentence each"]}"""
2767+
"priorities": ["the three things to fix first, one sentence each"]}
2768+
2769+
Whenever you mention a SAST finding, give its id (GCR- and twelve characters)."""
26752770

26762771
RISK_SYSTEM = """You assess the security risk of one public web site from two \
26772772
sources: what a dynamic scan (DAST) found on the live site and what static \
@@ -2684,7 +2779,9 @@ def recover() -> int:
26842779
"summary": "four to six sentences in plain US English for a business owner",
26852780
"correlations": [{"dast": "DAST finding title", "sast": "SAST finding id and rule", \
26862781
"why": "one sentence"}],
2687-
"next_steps": ["up to five actions, most important first"]}"""
2782+
"next_steps": ["up to five actions, most important first"]}
2783+
2784+
Whenever you mention a SAST finding, give its id (GCR- and twelve characters)."""
26882785

26892786

26902787
def _json_answer(text: str) -> dict:

‎app/web/routes_external.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,8 @@ def detail_page(asset_id: int, request: Request, scans: int = 1,
664664
review = json.loads(asset.ai_review or "{}") if asset.ai_review else {}
665665
except ValueError:
666666
review = {}
667+
if isinstance(review, dict) and review:
668+
review = external.scrub_review(db, review)
667669
active_scan = running.get(asset.id)
668670
busy = (asset.telemetry_status == "running" or asset.ai_status == "running"
669671
or active_scan is not None

‎app/web/templates/dast_report_pdf.html‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,10 @@ <h2 class="new-page" style="margin-top:0">1. Document control</h2>
137137

138138
<h2>2. Executive summary</h2>
139139
{% for paragraph in narrative %}<p>{{ paragraph | sentence_spaced }}</p>{% endfor %}
140-
{% if ai_summary and ai_summary.summary %}
140+
{% if ai_summary and (ai_summary.summary or ai_summary.withheld) %}
141141
<h3>2.1 What the enhanced review found</h3>
142-
<p>{{ ai_summary.summary | sentence_spaced }}</p>
142+
{% if ai_summary.summary %}<p>{{ ai_summary.summary | sentence_spaced }}</p>
143+
{% else %}<p>{{ "The review's summary is left out. It named code findings that have since been given an exception or marked a false positive. Run the enhanced review again for a new one." | sentence_spaced }}</p>{% endif %}
143144
{% if ai_summary.priorities %}<ol>{% for p in ai_summary.priorities %}<li>{{ p | sentence_spaced }}</li>{% endfor %}</ol>{% endif %}
144145
<p class="faint">Written from the findings in section 4. Check it against them.</p>
145146
{% endif %}

‎app/web/templates/external_scan.html‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@
144144
{% if ai.status == 'running' or ai.status == 'wanted' %}<div class="faint">{{ 'Waiting for the scan to finish.' if ai.status == 'wanted' else 'Asking the model.' }}</div>
145145
{% elif ai.status == 'failed' %}<div class="notice bad"><div>{{ (ai.summary or {}).error or 'The model gave no usable answer.' }}</div></div>
146146
{% else %}
147-
<p>{{ ai.summary.summary }}</p>
147+
{% if ai.summary.summary %}<p>{{ ai.summary.summary }}</p>
148+
{% elif ai.summary.withheld %}<div class="notice info"><div>The summary is hidden. It named {{ ai.summary.withheld }} code finding{{ '' if ai.summary.withheld == 1 else 's' }} since given an exception or marked a false positive. Use AI triage again for a new one.</div></div>{% endif %}
148149
{% if ai.summary.priorities %}<ol class="small">{% for p in ai.summary.priorities %}<li>{{ p }}</li>{% endfor %}</ol>{% endif %}
149150
<div class="small faint">The model's verdicts are shown on each finding below. They do not change any severity.
150151
<a href="{{ here }}?verdict=likely_real">Likely real</a> &middot;

‎app/web/templates/external_site.html‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@
173173
{% elif asset.ai_status == 'failed' %}<div class="notice bad"><div>{{ asset.ai_error }}</div></div>{% endif %}
174174
{% if review %}
175175
<div><span class="badge {{ review.rating }}">{{ review.rating }}</span> <span class="small faint">{{ asset.ai_at | dt('%Y-%m-%d %H:%M') }}{% if review.model %}, {{ review.model }}{% endif %}. Written by a model from the findings below: check it against them.</span></div>
176-
<p>{{ review.summary }}</p>
176+
{% if review.summary %}<p>{{ review.summary }}</p>
177+
{% elif review.withheld %}<div class="notice info"><div>The summary is hidden. It named {{ review.withheld }} code finding{{ '' if review.withheld == 1 else 's' }} since given an exception or marked a false positive. Read it again for a new one.</div></div>{% endif %}
177178
{% if review.correlations %}<div class="small"><strong>Seen in both the site and the code</strong>
178179
{% for c in review.correlations %}<div style="margin:.25rem 0">{{ c.dast }} &harr; <span class="mono">{{ c.sast }}</span><div class="faint">{{ c.why }}</div></div>{% endfor %}</div>{% endif %}
179180
{% if review.next_steps %}<div class="small" style="margin-top:.5rem"><strong>Next</strong><ol>{% for s in review.next_steps %}<li>{{ s }}</li>{% endfor %}</ol></div>{% endif %}

‎documentation/CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ Newest first. Written for somebody deciding whether to upgrade, so every entry
44
says what is different for a person using the product rather than which files
55
moved.
66

7+
## 2026-11-05
8+
9+
**DAST reports leave out code findings with an exception or a false
10+
positive.** A scan's AI triage and a site's AI risk read keep what the model
11+
wrote, so a code finding given an exception afterward went on appearing in
12+
the PDF. Now, when a saved summary names a code finding that has since been
13+
excepted or marked a false positive, the summary is hidden with a note to run
14+
it again, and any priority, next step, match or correlation naming one is
15+
dropped. The code finding counts beside a site also leave out exceptions.
16+
717
## 2026-11-04
818

919
**Upgrades wait for running scans, and for a slow start.** `install.sh

‎documentation/external-sites.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,14 @@ Every call is in the AI cost page under `dast-triage` or `external-risk`. The
392392
scan and the site also keep their own token counts and cost. Path traversal
393393
and script payloads in engine evidence are left out of what is sent.
394394

395+
Code findings with an exception, a not deployed scope, or a false positive
396+
grade (info) are never sent, counted or shown. A saved triage or risk read
397+
was written from the findings as they were at the time, so it is checked
398+
each time it is shown. When its summary names a code finding that has since
399+
been excepted or marked a false positive, the summary is hidden, on the page
400+
and in the PDF, until it is run again. Priorities, next steps, matches and
401+
correlations that name one are dropped. A fixed finding is not hidden.
402+
395403
## API
396404

397405
Make a token with the `external` scope. Only an administrator can, and the

0 commit comments

Comments
 (0)