Skip to content

Commit 9627011

Browse files
committed
Weigh a weakness by the data the application holds, drawn from the code behind it
A weakness on a site that holds financial reporting data, payment cards or personal records is a bigger breach than the same weakness on a brochure site. The scan now says so. A new classifier reads what the code behind a site handles, reusing the sensitivity labels, application name and repository names already kept on the linked repositories, and places it into classes: financial reporting data under SOX, payment card data, health data, controlled unclassified information, sensitive personal data, personal data, customer data, credentials. Even a mailing address counts, as personal data and, in a federal context, as controlled unclassified information. A site whose code says nothing gets no class, so nothing is invented. That profile raises the impact side of the risk score by the most serious class present, once, not stacked, and the existing discipline still holds a noisy low finding at its own severity. The enhanced review is given the profile and the plain consequence of a breach, and told to state that consequence in real terms, SOX fines, breach notification, customer and reputational loss, using only the consequence provided and inventing none. The site page and the report name the data the application holds and where it was read from. This is the mix the whole thing is built for: what the code says the application holds, meeting what the live scan can reach.
1 parent 93a9dfe commit 9627011

7 files changed

Lines changed: 235 additions & 14 deletions

File tree

‎app/core/dast_report.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,13 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
343343
+ base64.b64encode(p.read_bytes()).decode())
344344
findings.append(f)
345345
overall = score(findings, triaged)
346-
# Risk, deterministic: likelihood x breachability, weighted by exposure.
347-
from app.core import dast_precedent, dast_risk
346+
# Risk, deterministic: likelihood x breachability, weighted by exposure and
347+
# by the data the app holds (derived from the code linked to this site).
348+
from app.core import data_classification, dast_precedent, dast_risk
348349

349-
dast_risk.annotate(findings, getattr(asset, "exposure", "") or "")
350+
data_profile = data_classification.profile_for_asset(db, asset)
351+
dast_risk.annotate(findings, getattr(asset, "exposure", "") or "",
352+
data_profile["classes"])
350353
# The verified real world precedent for each weakness class, from the vetted
351354
# table, so the report shows a factual comparable case even with no AI triage.
352355
dast_precedent.annotate(findings)
@@ -443,7 +446,7 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
443446
return {
444447
"asset": asset, "scan": scan, "view": view, "findings": findings,
445448
"overall": overall, "by_sev": by_sev, "categories": categories,
446-
"risk_matrix": risk_matrix,
449+
"risk_matrix": risk_matrix, "data_profile": data_profile,
447450
"reachable_sinks": reachable_sinks, "context_sinks": context_sinks,
448451
"heat_max": heat_max, "triaged": triaged, "ai_summary": ai_summary,
449452
"narrative": narrative(asset.host, view, overall, by_sev, triaged, profile_words),

‎app/core/dast_risk.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ def likelihood(f: dict) -> tuple:
6565
return base, why
6666

6767

68-
def breachability(f: dict, exposure: str = "") -> tuple:
69-
"""1..5 and a short basis: what a hit yields, weighted by exposure."""
68+
def breachability(f: dict, exposure: str = "", data_classes=None) -> tuple:
69+
"""1..5 and a short basis: what a hit yields, weighted by exposure and by
70+
the kind of data the application holds."""
7071
text = f"{f.get('title') or ''} {f.get('cwe') or ''} {f.get('evidence') or ''}"
7172
reasons = []
7273
if _RCE.search(text):
@@ -90,6 +91,13 @@ def breachability(f: dict, exposure: str = "") -> tuple:
9091
if _SENSITIVE_URL.search(str(f.get("url") or "")):
9192
impact = min(5, impact + 1)
9293
reasons.append("a sensitive endpoint")
94+
if data_classes:
95+
from app.core import data_classification
96+
97+
bump, why = data_classification.impact_bump(data_classes)
98+
if bump:
99+
impact = min(5, impact + bump)
100+
reasons.append(why)
93101
return impact, "; ".join(reasons)
94102

95103

@@ -143,11 +151,11 @@ def corroborate(findings: list, reachable_sinks: list) -> int:
143151
"low": "low", "info": "low", "none": "low"}
144152

145153

146-
def annotate(findings: list, exposure: str = "") -> list:
154+
def annotate(findings: list, exposure: str = "", data_classes=None) -> list:
147155
"""Add likelihood, breachability, risk and a basis to each finding, in place."""
148156
for f in findings:
149157
lk, lk_why = likelihood(f)
150-
br, br_why = breachability(f, exposure)
158+
br, br_why = breachability(f, exposure, data_classes)
151159
f["likelihood"] = lk
152160
f["breachability"] = br
153161
f["likelihood_label"] = LIKELIHOOD_LABEL[lk]

‎app/core/data_classification.py‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""What data an application likely holds, and what a breach of it would mean.
2+
3+
The dynamic scan sees a web site from the outside. The code behind it, and the
4+
tags people already put on those repositories, say what the application handles:
5+
customer records, personal data, payment cards, health data, financial reporting
6+
data under SOX, controlled unclassified information. That classification is the
7+
other half of risk. A weakness on an app that holds SOX or card data is a
8+
bigger breach than the same weakness on a brochure site, and the report should
9+
say so, in the real terms a public company cares about: fines, breach
10+
notification, reputational loss.
11+
12+
This module derives that profile from the repositories linked to a DAST asset,
13+
reusing the sensitivity labels, application name and repository name that the
14+
tagging already maintains, and turns it into an impact weight and a plain
15+
consequence. It is deterministic: a keyword read of tags that already exist, so
16+
the report can state the consequence as fact, not a guess dressed up as one.
17+
18+
Even a mailing address counts: it is personal data, and in a federal context it
19+
can be controlled unclassified information. So the classes run from the plainly
20+
regulated down to ordinary customer data, and ordinary customer data still
21+
matters.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import re
27+
28+
# Each class: a label, how much it raises the impact of a breach (0 to 2), and
29+
# the plain consequence a reader should understand. Ordered most serious first,
30+
# which is the order a dominant class is picked in.
31+
CLASSES = {
32+
"sox": {
33+
"label": "SOX financial reporting data",
34+
"bump": 2,
35+
"consequence": "For a publicly traded company a breach here can mean "
36+
"regulatory fines and a failure of financial reporting "
37+
"controls.",
38+
},
39+
"pci": {
40+
"label": "payment card data",
41+
"bump": 2,
42+
"consequence": "Exposure can bring payment card brand penalties and the "
43+
"loss of the ability to process cards.",
44+
},
45+
"phi": {
46+
"label": "health information",
47+
"bump": 2,
48+
"consequence": "Exposure can bring HIPAA penalties and mandatory breach "
49+
"notification.",
50+
},
51+
"cui": {
52+
"label": "controlled unclassified information",
53+
"bump": 2,
54+
"consequence": "Exposure can breach federal handling rules for controlled "
55+
"unclassified information; even a mailing address can be CUI.",
56+
},
57+
"spii": {
58+
"label": "sensitive personal data",
59+
"bump": 2,
60+
"consequence": "Exposure of data such as Social Security or financial "
61+
"numbers triggers breach notification laws and identity "
62+
"theft risk.",
63+
},
64+
"pii": {
65+
"label": "personal data",
66+
"bump": 1,
67+
"consequence": "Exposure triggers privacy law obligations and direct harm "
68+
"to the people whose data it is.",
69+
},
70+
"customer": {
71+
"label": "customer data",
72+
"bump": 1,
73+
"consequence": "A breach exposes customers and brings reputational loss.",
74+
},
75+
"credentials": {
76+
"label": "credentials or secrets",
77+
"bump": 1,
78+
"consequence": "Exposed credentials let an attacker reach whatever they "
79+
"unlock.",
80+
},
81+
}
82+
83+
ORDER = ["sox", "pci", "phi", "cui", "spii", "pii", "customer", "credentials"]
84+
85+
# Keywords that place a tag, application name or repository name into a class.
86+
# Read case insensitively over text people already wrote, so it is a floor, not
87+
# a guess: a match means the word is there.
88+
_KEYWORDS = {
89+
"sox": r"\bsox\b|sarbanes|financial report|general ledger|\bg/?l\b|\berp\b|"
90+
r"e-?business suite|\bebs\b|revenue recognition|accounts (payable|receivable)|"
91+
r"\bledger\b|audit trail",
92+
"pci": r"\bpci\b|cardholder|credit.?card|payment card|\bpan\b|card number|"
93+
r"card data",
94+
"phi": r"\bphi\b|hipaa|patient|health record|\behr\b|\bemr\b|medical record",
95+
"cui": r"\bcui\b|controlled unclassified|\bitar\b|export control|\bfouo\b|dfars|"
96+
r"\bcmmc\b",
97+
"spii": r"\bspii\b|social security|\bssn\b|passport|driver.?s?.? ?licen|biometric|"
98+
r"bank account|routing number|\btax id\b",
99+
"pii": r"\bpii\b|personal data|personally identifiable|mailing address|"
100+
r"\baddress(es)?\b|date of birth|\bdob\b|phone number|email address",
101+
"customer": r"customer|client\b|subscriber|account holder|\bmember\b|consumer|"
102+
r"\bcrm\b",
103+
"credentials": r"credential|password|\bsecret(s)?\b|api.?key|private key|token",
104+
}
105+
_COMPILED = {cls: re.compile(pat, re.I) for cls, pat in _KEYWORDS.items()}
106+
107+
108+
def classify(text: str) -> set:
109+
"""The data classes a blob of text points to. A keyword read, nothing more."""
110+
t = str(text or "")
111+
return {cls for cls, pat in _COMPILED.items() if pat.search(t)}
112+
113+
114+
def rank(classes) -> list:
115+
"""The classes present, most serious first."""
116+
present = set(classes or [])
117+
return [c for c in ORDER if c in present]
118+
119+
120+
def impact_bump(classes) -> tuple:
121+
"""How much these classes raise the impact of a breach, and why.
122+
123+
The bump is the single most serious class, not a sum, so a known class lifts
124+
the impact once; it does not stack an app up to the ceiling on its own."""
125+
ranked = rank(classes)
126+
if not ranked:
127+
return 0, ""
128+
bump = max(CLASSES[c]["bump"] for c in ranked)
129+
labels = ", ".join(CLASSES[c]["label"] for c in ranked)
130+
return bump, f"the application holds {labels}"
131+
132+
133+
def consequence(classes) -> str:
134+
"""One plain statement of what a breach of this data would mean."""
135+
ranked = rank(classes)
136+
if not ranked:
137+
return ""
138+
out = CLASSES[ranked[0]]["consequence"]
139+
extra = [CLASSES[c]["label"] for c in ranked[1:]]
140+
if extra:
141+
out += " The application also appears to hold " + ", ".join(extra) + "."
142+
return out
143+
144+
145+
def labels(classes) -> list:
146+
"""Human labels for the classes, most serious first."""
147+
return [CLASSES[c]["label"] for c in rank(classes)]
148+
149+
150+
def _linked_repos(db, asset):
151+
from app.core.models import ExternalAssetRepo, Repository
152+
153+
return (db.query(Repository)
154+
.join(ExternalAssetRepo, ExternalAssetRepo.repository_id == Repository.id)
155+
.filter(ExternalAssetRepo.asset_id == asset.id).all())
156+
157+
158+
def profile_for_asset(db, asset) -> dict:
159+
"""Derive the data profile of a DAST asset from the code linked to it.
160+
161+
-> {classes: [...], labels: [...], consequence: "...", bump: int,
162+
basis: "...", from_repos: [...]}. Empty classes when the linked
163+
repositories carry nothing that places them in a class, so nothing is
164+
invented: a profile only appears when a tag, an application name or a
165+
repository name actually says so.
166+
"""
167+
classes, from_repos = set(), []
168+
for r in _linked_repos(db, asset):
169+
text = " ".join([r.sensitivity or "", r.application_name or "", r.name or ""])
170+
found = classify(text)
171+
if found:
172+
classes |= found
173+
from_repos.append(r.full_name)
174+
ranked = rank(classes)
175+
bump, why = impact_bump(ranked)
176+
basis = ""
177+
if ranked:
178+
basis = ("From the code behind this site: " + ", ".join(labels(ranked))
179+
+ (f" (seen in {', '.join(from_repos[:6])}"
180+
+ (" and more" if len(from_repos) > 6 else "") + ")"
181+
if from_repos else "") + ".")
182+
return {"classes": ranked, "labels": labels(ranked),
183+
"consequence": consequence(ranked), "bump": bump,
184+
"basis": basis, "from_repos": from_repos}

‎app/core/external.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3753,7 +3753,11 @@ def recover() -> int:
37533753
37543754
2) Write "why" as a short risk narrative for an engineer, three to five \
37553755
sentences, in this order:
3756-
- what can go wrong: what an attacker gains if the finding is real;
3756+
- what can go wrong: what an attacker gains if the finding is real, and, when \
3757+
the finding would expose the data the app holds, what that breach means in real \
3758+
terms using ONLY the consequence provided in data_the_app_holds (for example SOX \
3759+
fines, breach notification, customer and reputational loss); name no consequence \
3760+
that was not provided;
37573761
- how an abuser attacks it: the concrete steps against THIS finding, at this url;
37583762
- how likely it is to lead to a breach: reason from the site's exposure (open \
37593763
to the world raises it, allow listed lowers it), the attack surface and \
@@ -3921,14 +3925,18 @@ def triage(scan_id: int, actor: str = "") -> None:
39213925
host = asset.host
39223926
exposure = (getattr(asset, "exposure", "") or "")
39233927
exposure_detail = (getattr(asset, "exposure_detail", "") or "")
3928+
from app.core import data_classification
3929+
3930+
data_profile = data_classification.profile_for_asset(db, asset)
39243931
cap = max(1, int(settings_mod.get(db, "dast.ai_max_findings") or 40))
39253932
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.
3933+
# Deterministic risk (likelihood x breachability, weighted by exposure and by
3934+
# the data the app holds) and the verified real world precedent for each
3935+
# weakness class, computed before the model is asked, so it reasons from
3936+
# these facts rather than its memory.
39293937
from app.core import dast_precedent, dast_risk
39303938

3931-
dast_risk.annotate(all_findings, exposure)
3939+
dast_risk.annotate(all_findings, exposure, data_profile["classes"])
39323940
dast_precedent.annotate(all_findings)
39333941
# Reuse a verdict for any finding unchanged since the last reviewed scan.
39343942
reused = 0
@@ -3961,6 +3969,11 @@ def triage(scan_id: int, actor: str = "") -> None:
39613969
"unknown means the exposure check did not run"),
39623970
"detail": _clean(exposure_detail)[:200],
39633971
},
3972+
"data_the_app_holds": {
3973+
"classes": data_profile["labels"],
3974+
"consequence_if_breached": data_profile["consequence"],
3975+
"source": "derived from the code repositories linked to this site",
3976+
},
39643977
"dast_findings": [{"id": f.get("id") or f["key"], "tool": f.get("tool"),
39653978
"severity": f.get("severity"), "title": _clean(f.get("title")),
39663979
"url": _clean(f.get("url"))[:300],

‎app/web/routes_external.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1285,9 +1285,10 @@ def scan_page(asset_id: int, scan_id: int, request: Request, sev: str = "",
12851285
findings = view["findings"]
12861286
# Verified real world precedent per weakness class, from the vetted table,
12871287
# so a finding shows its factual comparable case even before AI triage.
1288-
from app.core import dast_precedent
1288+
from app.core import data_classification, dast_precedent
12891289

12901290
dast_precedent.annotate(findings)
1291+
data_profile = data_classification.profile_for_asset(db, asset)
12911292
removed = external.removed_of(scan)
12921293
if isinstance(view["ai"].get("summary"), dict):
12931294
view["ai"]["summary"] = external.scrub_dast_review(view["ai"]["summary"], findings,
@@ -1307,6 +1308,7 @@ def scan_page(asset_id: int, scan_id: int, request: Request, sev: str = "",
13071308
scope = (_dast_paths.scoping(_dast_paths.load(scan.paths).get("paths") or [])
13081309
if scan.paths_count is not None else None)
13091310
context.update(active_page="external", asset=asset, scan=scan, view=view,
1311+
data_profile=data_profile,
13101312
scope=scope, blocked_text=_dast_paths.blocked_text(scope or {}),
13111313
code=external.code_behind(db, user, scan, asset),
13121314
findings=findings, sev=sev, verdict=verdict, decided=decided,

‎app/web/templates/dast_report_pdf.html‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ <h2 class="new-page" style="margin-top:0">1. Document control</h2>
140140

141141
<h2>2. Executive summary</h2>
142142
{% for paragraph in narrative %}<p>{{ paragraph | sentence_spaced }}</p>{% endfor %}
143+
{% if data_profile and data_profile.classes %}
144+
<p><strong>What this application holds.</strong> From the code behind this site it handles
145+
{{ data_profile.labels | join(', ') }}. {{ data_profile.consequence | sentence_spaced }}
146+
The findings below weigh this in: a weakness that reaches this data is a larger breach than
147+
the same weakness on a site that holds none.</p>
148+
{% endif %}
143149
{% if ai_summary and (ai_summary.summary or ai_summary.withheld or ai_summary.withheld_dast) %}
144150
<h3>2.1 What the enhanced review found</h3>
145151
{% if ai_summary.summary %}<p>{{ ai_summary.summary | sentence_spaced }}</p>

‎app/web/templates/external_scan.html‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
{% endif %}
4949
</div>
5050

51+
{% if data_profile and data_profile.classes %}
52+
<div class="notice info"><div><strong>Data this app likely holds:</strong>
53+
{% for lbl in data_profile.labels %}<span class="badge pending">{{ lbl }}</span> {% endfor %}
54+
<span class="faint small">{{ data_profile.basis }} This raises the impact of a breach here.</span></div></div>
55+
{% endif %}
5156
<div class="stats" data-live="scan-stats">
5257
<div class="stat {{ 'bad' if view.severity in ('critical','high') else ('ok' if scan.status == 'done' and view.severity in ('', 'none') else '') }}"><div class="n" style="{{ 'font-size:1.1rem' if view.severity in ('', 'none') }}">{% if scan.status != 'done' %}&ndash;{% elif view.severity in ('', 'none') %}no findings{% else %}{{ view.severity }}{% endif %}</div><div class="l">Worst found</div></div>
5358
{% for level in ['critical','high','medium','low','info'] %}

0 commit comments

Comments
 (0)