Skip to content

Commit 93a9dfe

Browse files
committed
Explain each weakness as a grounded risk: what can go wrong, how it is attacked, how likely
The enhanced review now writes each finding as a short risk narrative, not one line: what an attacker gains, how they would attack this finding at this address, and how likely it is to lead to a breach. The likelihood reasons from the site's exposure (reachable from the open internet raises it, allow listed lowers it), the attack surface and reachability, any compensating control visible in the evidence, and the reality that attackers now automate discovery and exploitation at scale, so an internet reachable, easily automated weakness is reached sooner than its manual difficulty suggests. Real world examples are kept factual. They do not come from the model, which would invent them. A vetted table maps each weakness class to a documented public incident and a checkable reference, the report renders that straight from the table, and the model is handed the matching entry as a fact to reason from with a hard rule never to invent a case, a number or an identifier. The review is also given the deterministic risk score and the exposure so its words match the numbers. A finding with no documented precedent gets none rather than an inflated one. The site page and the PDF show how a weakness class is abused and its real world precedent, from the vetted table, even before the enhanced review runs.
1 parent fc8a4f5 commit 93a9dfe

6 files changed

Lines changed: 247 additions & 19 deletions

File tree

‎app/core/dast_precedent.py‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Verified real world precedent for a weakness class.
2+
3+
The point is factual grounding. A language model asked for a real world
4+
example of a weakness will happily invent a CVE number, a company and a breach
5+
size. So the examples do not come from the model: they live here, vetted, each
6+
one a well documented public incident with a reference a reader can check. The
7+
triage prompt is handed the matching entry as a fact to reason from, and the
8+
report renders it straight from this table, so what a reader sees is sourced
9+
here, not from the model's memory.
10+
11+
Each entry is one weakness class: how an attacker abuses it, a documented real
12+
world case, and a checkable reference. A class is only listed when there is a
13+
genuinely well documented public incident for it; a low or informational
14+
finding with no real precedent gets none, rather than an inflated one.
15+
16+
Match order matters: the most specific, highest impact classes come first, so
17+
an injection that leads to code execution is read as code execution, not as a
18+
generic injection.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import re
24+
25+
# Each: a matcher over the finding's text, the OWASP and CWE anchors, how the
26+
# class is abused, and a documented real world case with a reference. Keep the
27+
# facts conservative and checkable; never add a case that is not well sourced.
28+
PRECEDENTS = [
29+
{
30+
"key": "rce",
31+
"match": re.compile(r"remote code|command inj|\brce\b|code exec|os command|"
32+
r"deserial|CWE-?(?:78|94|77|502)\b", re.I),
33+
"ref": "CVE-2017-5638; OWASP A03:2021",
34+
"abuse": "The attacker sends crafted input that the server runs as code, "
35+
"takes control of the host, then reaches the data and systems "
36+
"behind it.",
37+
"precedent": "The 2017 Equifax breach, which exposed data on about 147 "
38+
"million people, began with an unpatched remote code "
39+
"execution flaw in Apache Struts (CVE-2017-5638).",
40+
},
41+
{
42+
"key": "ssrf",
43+
"match": re.compile(r"\bssrf\b|server.side request|CWE-?918\b", re.I),
44+
"ref": "OWASP A10:2021; CWE-918",
45+
"abuse": "The attacker makes the server fetch a URL of their choosing, "
46+
"reaching internal services the outside cannot, such as a cloud "
47+
"metadata endpoint, and reading back what it returns.",
48+
"precedent": "The 2019 Capital One breach, which affected about 100 "
49+
"million people, used server side request forgery to reach "
50+
"the cloud metadata service and take credentials from it.",
51+
},
52+
{
53+
"key": "sqli",
54+
"match": re.compile(r"sql\s*inj|\bsqli\b|CWE-?89\b", re.I),
55+
"ref": "OWASP A03:2021; CWE-89",
56+
"abuse": "The attacker puts database syntax into a parameter to read or "
57+
"change data the query never meant to expose, often dumping whole "
58+
"tables of accounts or payment records.",
59+
"precedent": "SQL injection was the entry point for the 2008 Heartland "
60+
"Payment Systems breach, about 130 million card numbers, and "
61+
"the 2015 TalkTalk breach, for which the UK regulator fined "
62+
"the company.",
63+
},
64+
{
65+
"key": "xss",
66+
"match": re.compile(r"\bxss\b|cross.site.script|CWE-?79\b", re.I),
67+
"ref": "OWASP A03:2021; CWE-79",
68+
"abuse": "The attacker gets script to run in another user's session, so it "
69+
"acts as that user: stealing the session, changing their account, "
70+
"or spreading to everyone who views the page.",
71+
"precedent": "The 2005 Samy worm used a stored cross site script to add "
72+
"over a million MySpace friends in under a day, by running in "
73+
"the session of everyone who viewed an infected profile.",
74+
},
75+
{
76+
"key": "bola",
77+
"match": re.compile(r"\bidor\b|\bbola\b|broken object|object level auth|"
78+
r"insecure direct object|CWE-?639\b", re.I),
79+
"ref": "OWASP API1:2023; CWE-639",
80+
"abuse": "The attacker changes an id in a URL or request and reads or edits "
81+
"records that belong to other users, walking the ids to reach many "
82+
"accounts at once.",
83+
"precedent": "The 2019 First American Financial exposure, about 885 million "
84+
"documents, and the 2018 USPS Informed Visibility API both let "
85+
"anyone read other people's records by changing an id, each "
86+
"documented publicly at the time.",
87+
},
88+
{
89+
"key": "secret",
90+
"match": re.compile(r"api.?key|secret|hardcoded|private key|credential|"
91+
r"access.key|\.htpasswd|\.npmrc|id_rsa|CWE-?798\b", re.I),
92+
"ref": "CWE-798; OWASP A05:2021",
93+
"abuse": "The attacker reads the exposed key or password and uses it to sign "
94+
"in to the service it belongs to, with whatever access that key "
95+
"was granted, no guessing required.",
96+
"precedent": "The 2016 Uber breach, about 57 million riders and drivers, "
97+
"happened because a cloud access key sat in a code repository "
98+
"the attackers were able to reach.",
99+
},
100+
{
101+
"key": "graphql-amplification",
102+
"match": re.compile(r"amplif|aliased|batch|rate limit|CWE-?770\b|"
103+
r"API4:2023", re.I),
104+
"ref": "OWASP API4:2023; CWE-770",
105+
"abuse": "The attacker packs many operations into one request, so a single "
106+
"call becomes many login or one time code attempts, and the per "
107+
"request rate limit and edge counting never see the real volume.",
108+
"precedent": "Bypassing login and one time code rate limits by batching or "
109+
"aliasing GraphQL operations is a documented technique, "
110+
"recognised in the OWASP API Security Top 10 as unrestricted "
111+
"resource consumption.",
112+
},
113+
{
114+
"key": "credential-stuffing",
115+
"match": re.compile(r"brute.?forc|credential stuff|weak password|"
116+
r"password spray|CWE-?307\b", re.I),
117+
"ref": "OWASP A07:2021; CWE-307",
118+
"abuse": "The attacker tries large lists of guessed or reused passwords "
119+
"against the login, and every account whose password is weak or "
120+
"reused is taken over.",
121+
"precedent": "Credential stuffing against Nintendo in 2020 compromised "
122+
"about 300,000 accounts by reusing passwords leaked from other "
123+
"sites.",
124+
},
125+
{
126+
"key": "data-exposure",
127+
"match": re.compile(r"directory listing|directory index|exposed backup|"
128+
r"\.bak\b|\.sql\b|open to the world|publicly readable|"
129+
r"exposed .*(database|dump|backup)|CWE-?(?:538|548)\b", re.I),
130+
"ref": "OWASP A05:2021; CWE-200",
131+
"abuse": "The attacker reads files or listings the site left open, taking "
132+
"backups, database dumps or source that were never meant to be "
133+
"public.",
134+
"precedent": "Misconfigured, publicly readable cloud storage has exposed "
135+
"data repeatedly, such as the 2017 exposure of about 14 "
136+
"million Verizon customer records left open on a storage "
137+
"bucket.",
138+
},
139+
]
140+
141+
142+
def match(f: dict):
143+
"""The precedent for a finding's weakness class, or None.
144+
145+
Matched on the finding's own text, most specific class first, so nothing is
146+
attached unless the finding clearly belongs to a class with a documented
147+
real world case."""
148+
text = " ".join(str(f.get(k) or "") for k in ("title", "cwe", "evidence", "url"))
149+
for p in PRECEDENTS:
150+
if p["match"].search(text):
151+
return {"class": p["key"], "abuse": p["abuse"],
152+
"precedent": p["precedent"], "reference": p["ref"]}
153+
return None
154+
155+
156+
def annotate(findings: list) -> list:
157+
"""Add the verified abuse description, real world precedent and reference to
158+
each finding that belongs to a documented class, in place. Findings with no
159+
documented precedent are left untouched, never given an inflated one."""
160+
for f in findings:
161+
p = match(f)
162+
if p:
163+
f["abuse"] = p["abuse"]
164+
f["precedent"] = p["precedent"]
165+
f["precedent_ref"] = p["reference"]
166+
f["weakness_class"] = p["class"]
167+
return findings

‎app/core/dast_report.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,12 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
344344
findings.append(f)
345345
overall = score(findings, triaged)
346346
# Risk, deterministic: likelihood x breachability, weighted by exposure.
347-
from app.core import dast_risk
347+
from app.core import dast_precedent, dast_risk
348348

349349
dast_risk.annotate(findings, getattr(asset, "exposure", "") or "")
350+
# The verified real world precedent for each weakness class, from the vetted
351+
# table, so the report shows a factual comparable case even with no AI triage.
352+
dast_precedent.annotate(findings)
350353
# Code sinks from the mapped repositories, split by whether the scan reached
351354
# the route they sit behind. Reachable ones are leads to prove; the rest
352355
# are context to review, not dynamic bugs.

‎app/core/external.py‎

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3738,26 +3738,52 @@ def recover() -> int:
37383738

37393739
TRIAGE_SYSTEM = """You review the results of a dynamic application security scan \
37403740
(DAST) of one public web site. You also get the open static analysis (SAST) \
3741-
findings from the source code repositories believed to be behind the site.
3742-
3743-
For each DAST finding decide: likely_real, likely_false_positive, or \
3744-
needs_review. Scanners report many false positives: version banners that are \
3745-
patched, generic header advice, reflected input that is encoded, and template \
3746-
matches on a soft 404 page. When a SAST finding in the code supports a DAST \
3747-
finding (same weakness, same route or parameter), say so; that raises \
3748-
confidence a great deal.
3749-
3750-
A finding you call likely_false_positive is taken out of the results, so use \
3751-
it only when the evidence shows it is noise. When unsure, say needs_review. \
3752-
A finding about an exposed secret stays whatever you say, so judge it anyway.
3753-
3754-
Do not change severities. Do not invent findings.
3741+
findings from the code behind the site, the site's exposure from a two vantage \
3742+
check, and, for each finding, a deterministic risk score and, when the weakness \
3743+
is a known class, a VERIFIED real world precedent.
3744+
3745+
For each DAST finding do two things.
3746+
3747+
1) Decide a verdict: likely_real, likely_false_positive, or needs_review. \
3748+
Scanners report many false positives: version banners that are patched, generic \
3749+
header advice, reflected input that is encoded, and template matches on a soft \
3750+
404 page. A finding you call likely_false_positive is taken out of the \
3751+
results, so use it only when the evidence shows it is noise. When unsure, say \
3752+
needs_review. A finding about an exposed secret stays whatever you say.
3753+
3754+
2) Write "why" as a short risk narrative for an engineer, three to five \
3755+
sentences, in this order:
3756+
- what can go wrong: what an attacker gains if the finding is real;
3757+
- how an abuser attacks it: the concrete steps against THIS finding, at this url;
3758+
- how likely it is to lead to a breach: reason from the site's exposure (open \
3759+
to the world raises it, allow listed lowers it), the attack surface and \
3760+
reachability (a sensitive or unauthenticated endpoint, a path reachable with no \
3761+
session), any compensating control visible in the evidence (authentication \
3762+
required, input encoded, a patch already applied), and the fact that modern \
3763+
attackers automate discovery and exploitation at scale, increasingly with AI \
3764+
assistance, so an internet reachable and easily automated weakness (enumeration, \
3765+
guessing, injection, or an exposed secret that bots scrape within minutes) is \
3766+
more likely to be reached than its manual difficulty suggests.
3767+
3768+
FACTS ONLY. This is a hard rule, never break it:
3769+
- Use ONLY the verified real world precedent provided with the finding. Never \
3770+
invent or recall a CVE number, a company, a breach size, a date or a statistic. \
3771+
If no precedent is provided, describe the mechanism and name no incident.
3772+
- A precedent is a comparable case, not this site's fate. Do not say this site \
3773+
was breached or will be.
3774+
- Do not overstate. If the exposure is allow listed, or a compensating control \
3775+
is present, say plainly that the likelihood is lower, and why.
3776+
- Keep the severities and the deterministic risk as given. Explain them; do \
3777+
not override them. Do not invent findings.
3778+
3779+
When a SAST finding in the code supports a DAST finding (same weakness, same \
3780+
route or parameter), say so; that raises confidence a great deal.
37553781
37563782
Answer with JSON only, in this shape:
37573783
{"summary": "three to five sentences for an engineer, plain US English",
3758-
"verdicts": [{"id": "finding id", "verdict": "likely_real", "why": "one sentence",
3784+
"verdicts": [{"id": "finding id", "verdict": "likely_real", "why": "the risk narrative",
37593785
"sast": "matching SAST finding id or empty"}],
3760-
"priorities": ["the three things to fix first, one sentence each"]}
3786+
"priorities": ["the three things to fix first, worst risk first, one sentence each"]}
37613787
37623788
Whenever you mention a SAST finding, give its id (GCR- and twelve characters). \
37633789
Whenever the summary or a priority is about a DAST finding, give its id in \
@@ -3893,8 +3919,17 @@ def triage(scan_id: int, actor: str = "") -> None:
38933919
if links:
38943920
sast = _sast_lines(sast_findings(db, [l["full_name"] for l in links], 60, who))
38953921
host = asset.host
3922+
exposure = (getattr(asset, "exposure", "") or "")
3923+
exposure_detail = (getattr(asset, "exposure_detail", "") or "")
38963924
cap = max(1, int(settings_mod.get(db, "dast.ai_max_findings") or 40))
38973925
prior = _prior_verdicts(db, scan.asset_id, scan_id)
3926+
# Deterministic risk (likelihood x breachability, weighted by exposure) and
3927+
# the verified real world precedent for each weakness class, computed before
3928+
# the model is asked, so it reasons from these facts rather than its memory.
3929+
from app.core import dast_precedent, dast_risk
3930+
3931+
dast_risk.annotate(all_findings, exposure)
3932+
dast_precedent.annotate(all_findings)
38983933
# Reuse a verdict for any finding unchanged since the last reviewed scan.
38993934
reused = 0
39003935
for f in findings:
@@ -3919,11 +3954,26 @@ def triage(scan_id: int, actor: str = "") -> None:
39193954
break
39203955
prompt = json.dumps({
39213956
"site": host,
3957+
"site_exposure": {
3958+
"reachable": exposure or "unknown",
3959+
"meaning": ("open means reachable from the open internet; "
3960+
"whitelisted means only from allow listed networks; "
3961+
"unknown means the exposure check did not run"),
3962+
"detail": _clean(exposure_detail)[:200],
3963+
},
39223964
"dast_findings": [{"id": f.get("id") or f["key"], "tool": f.get("tool"),
39233965
"severity": f.get("severity"), "title": _clean(f.get("title")),
39243966
"url": _clean(f.get("url"))[:300],
39253967
"evidence": _clean(f.get("evidence"))[:300],
3926-
"cwe": f.get("cwe")} for f in part],
3968+
"cwe": f.get("cwe"),
3969+
"computed_likelihood": f.get("likelihood_label"),
3970+
"computed_impact": f.get("breachability_label"),
3971+
"computed_risk": f.get("risk"),
3972+
"risk_basis": _clean(f.get("risk_basis"))[:300],
3973+
"verified_real_world_precedent": f.get("precedent") or "",
3974+
"precedent_reference": f.get("precedent_ref") or "",
3975+
"how_the_class_is_abused": f.get("abuse") or ""}
3976+
for f in part],
39273977
"sast_findings_in_code_behind_the_site": sast,
39283978
}, indent=1)
39293979
answer = ai.complete(cfg, TRIAGE_SYSTEM, prompt, purpose="dast-triage")
@@ -3954,7 +4004,7 @@ def triage(scan_id: int, actor: str = "") -> None:
39544004
verdict = str(v.get("verdict") or "")
39554005
f["ai_verdict"] = verdict if verdict in (
39564006
"likely_real", "likely_false_positive", "needs_review") else "needs_review"
3957-
f["ai_why"] = str(v.get("why") or "")[:400]
4007+
f["ai_why"] = str(v.get("why") or "")[:900]
39584008
f["ai_sast"] = str(v.get("sast") or "")[:40]
39594009
with session_scope() as db:
39604010
scan = db.get(ExternalDastScan, scan_id)

‎app/web/routes_external.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,11 @@ def scan_page(asset_id: int, scan_id: int, request: Request, sev: str = "",
12831283
external_decisions.for_asset(db, asset.id),
12841284
scan_id=scan.id)
12851285
findings = view["findings"]
1286+
# Verified real world precedent per weakness class, from the vetted table,
1287+
# so a finding shows its factual comparable case even before AI triage.
1288+
from app.core import dast_precedent
1289+
1290+
dast_precedent.annotate(findings)
12861291
removed = external.removed_of(scan)
12871292
if isinstance(view["ai"].get("summary"), dict):
12881293
view["ai"]["summary"] = external.scrub_dast_review(view["ai"]["summary"], findings,

‎app/web/templates/dast_report_pdf.html‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ <h3>{{ f.ref }} {{ f.title }}</h3>
246246
{% if f.risk %}<tr><td class="k">Risk</td><td><span class="badge r-{{ f.risk }}">{{ f.risk | upper }}</span>
247247
&nbsp;Likelihood {{ f.likelihood_label }}, impact {{ f.breachability_label }}.
248248
{% if f.risk_basis %}<span class="soft">{{ f.risk_basis | sentence_spaced }}</span>{% endif %}</td></tr>{% endif %}
249+
{% if f.abuse %}<tr><td class="k">How it is abused</td><td>{{ f.abuse | sentence_spaced }}</td></tr>{% endif %}
250+
{% if f.precedent %}<tr><td class="k">Real-world precedent</td><td>{{ f.precedent | sentence_spaced }} <span class="soft">({{ f.precedent_ref }})</span></td></tr>{% endif %}
249251
<tr><td class="k">OWASP Top 10</td><td>{{ f.owasp }} {{ f.owasp_name }}</td></tr>
250252
{% if f.cwe %}<tr><td class="k">CWE</td><td>{{ f.cwe }}</td></tr>{% endif %}
251253
{% if f.url %}<tr><td class="k">Location</td><td class="mono">{{ f.url }}</td></tr>{% endif %}

‎app/web/templates/external_scan.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@
200200
</div></details>{% endif %}
201201
{% if f.remediation %}<details class="small"><summary>How to fix</summary>{{ f.remediation }}</details>{% endif %}
202202
{% if f.ai_why %}<div class="small"><span class="badge ai">AI</span> {{ f.ai_why }}{% if f.ai_sast %} <span class="mono">{{ f.ai_sast }}</span>{% endif %}</div>{% endif %}
203+
{% if f.precedent %}<details class="small"><summary>Real-world precedent</summary>{{ f.precedent }} <span class="faint">({{ f.precedent_ref }})</span></details>{% endif %}
203204
{% if f.reference %}<div class="small faint">{{ f.reference }}</div>{% endif %}</td>
204205
<td class="small mono" style="word-break:break-all">{{ f.url }}
205206
{% if f.rolled_up %}<details class="small"><summary>{{ f.rolled_up }} addresses</summary>{% for u in f.urls %}<div>{{ u }}</div>{% endfor %}{% if f.rolled_up > f.urls | length %}<div class="faint">and {{ f.rolled_up - f.urls | length }} more</div>{% endif %}</details>{% endif %}</td>

0 commit comments

Comments
 (0)