Skip to content

Commit e4da488

Browse files
committed
Risk scoring: likelihood x breachability, exposure aware, with a PDF matrix
A new deterministic, token free risk model. Every dynamic finding gets a likelihood (how sure it is real and reachable: confirmed by a curated rule, matched by the baseline, or a scanning engine's heuristic) and a breachability (what a successful exploit yields, raised when the site is open to the world and lowered when it is allow listed, and when the endpoint is sensitive). The two place the finding on a five by five risk matrix. The PDF report gains a risk matrix heatmap and shows each finding's likelihood, impact and risk cell with the reasoning, so the same finding reads as higher risk on a site open to the world than on an allow listed one. This is risk, not a compliance count. No AI and no tokens are spent: the inputs are already in the scan.
1 parent ec87ad1 commit e4da488

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

‎app/core/dast_report.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
329329
if cwe else "")
330330
findings.append(f)
331331
overall = score(findings, triaged)
332+
# Risk, deterministic: likelihood x breachability, weighted by exposure.
333+
from app.core import dast_risk
334+
335+
dast_risk.annotate(findings, getattr(asset, "exposure", "") or "")
336+
risk_matrix = dast_risk.matrix(findings)
332337
from app.core.external_decisions import counted as _counted
333338

334339
by_sev = {s: sum(1 for f in findings if f.get("severity") == s and _counted(f))
@@ -410,6 +415,7 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
410415
return {
411416
"asset": asset, "scan": scan, "view": view, "findings": findings,
412417
"overall": overall, "by_sev": by_sev, "categories": categories,
418+
"risk_matrix": risk_matrix,
413419
"heat_max": heat_max, "triaged": triaged, "ai_summary": ai_summary,
414420
"narrative": narrative(asset.host, view, overall, by_sev, triaged, profile_words),
415421
"top": [f for f in findings if f.get("severity") in ("critical", "high")

‎app/core/dast_risk.py‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Risk scoring for dynamic findings: likelihood x breachability.
2+
3+
Deterministic and token free. Every signal comes from what the scan already
4+
knows: how the finding was reached (a curated rule that confirmed it, the
5+
baseline content check, or a scanning engine's heuristic), what it would yield,
6+
and how exposed the site is (the two vantage exposure check). No AI.
7+
8+
likelihood 1..5 how sure we are it is real and reachable
9+
breachability 1..5 what a successful exploit yields, weighted by exposure
10+
risk a cell on the 5x5 matrix: critical / high / medium / low
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import re
16+
17+
LIKELIHOOD_LABEL = {1: "Rare", 2: "Unlikely", 3: "Possible", 4: "Likely",
18+
5: "Confirmed"}
19+
IMPACT_LABEL = {1: "Minimal", 2: "Low", 3: "Moderate", 4: "High", 5: "Severe"}
20+
21+
# A standard risk heatmap: table[likelihood][breachability].
22+
_TABLE = {
23+
1: {1: "low", 2: "low", 3: "low", 4: "low", 5: "low"},
24+
2: {1: "low", 2: "low", 3: "medium", 4: "medium", 5: "high"},
25+
3: {1: "low", 2: "medium", 3: "medium", 4: "high", 5: "high"},
26+
4: {1: "medium", 2: "medium", 3: "high", 4: "high", 5: "critical"},
27+
5: {1: "medium", 2: "high", 3: "high", 4: "critical", 5: "critical"},
28+
}
29+
30+
_RCE = re.compile(r"remote code|command inj|\brce\b|deserial|CWE-?(?:78|94|502|77)\b", re.I)
31+
_ACCESS = re.compile(r"auth\w*\s*bypass|\bidor\b|\bbola\b|broken access|privilege|"
32+
r"CWE-?(?:285|639|287|863|862|306)\b", re.I)
33+
_INJECT = re.compile(r"sql\s*inj|\bsqli\b|\bssrf\b|\bxxe\b|\bssti\b|template inj|"
34+
r"CWE-?(?:89|918|611|1336|90|943)\b", re.I)
35+
_SENSITIVE_URL = re.compile(r"/admin|/api(?:/|$)|/account|/user|/order|/payment|/billing|"
36+
r"/invoice|/token|/secret|/internal|/manage|/config", re.I)
37+
_KEV = re.compile(r"\bKEV\b|known.exploit|actively exploited", re.I)
38+
39+
_SEV_IMPACT = {"critical": 4, "high": 4, "medium": 3, "low": 2, "info": 1, "none": 1}
40+
41+
42+
def _tools(f: dict) -> str:
43+
got = f.get("tools") or [f.get("tool") or f.get("check") or ""]
44+
return " ".join(str(t) for t in got).lower()
45+
46+
47+
def likelihood(f: dict) -> tuple:
48+
"""1..5 and a short basis, from how the finding was reached."""
49+
tools = _tools(f)
50+
answer = f.get("answer")
51+
if answer == "not_found":
52+
return 1, "the address answers not found"
53+
if tools.startswith("rule:") or "rule:" in tools:
54+
base, why = 5, "confirmed by a curated rule against a control"
55+
elif "baseline" in tools:
56+
base, why = 4, "the baseline check matched real content"
57+
elif answer == "protected":
58+
base, why = 2, "the path is access controlled"
59+
else:
60+
base, why = 3, "reported by a scanning engine, not re-proven"
61+
text = f"{f.get('title') or ''} {f.get('evidence') or ''} {f.get('cwe') or ''}"
62+
if _KEV.search(text) and base < 5:
63+
return min(5, base + 1), why + "; known exploited"
64+
return base, why
65+
66+
67+
def breachability(f: dict, exposure: str = "") -> tuple:
68+
"""1..5 and a short basis: what a hit yields, weighted by exposure."""
69+
text = f"{f.get('title') or ''} {f.get('cwe') or ''} {f.get('evidence') or ''}"
70+
reasons = []
71+
if _RCE.search(text):
72+
impact = 5
73+
reasons.append("remote code or command execution")
74+
elif _ACCESS.search(text):
75+
impact = 4
76+
reasons.append("access control or authentication")
77+
elif _INJECT.search(text):
78+
impact = 4
79+
reasons.append("injection or server side request forgery")
80+
else:
81+
impact = _SEV_IMPACT.get(f.get("severity"), 2)
82+
reasons.append(f"{f.get('severity') or 'low'} severity issue")
83+
if (exposure or "") == "open":
84+
impact = min(5, impact + 1)
85+
reasons.append("open to the world")
86+
elif (exposure or "") == "whitelisted":
87+
impact = max(1, impact - 1)
88+
reasons.append("allow listed, not public")
89+
if _SENSITIVE_URL.search(str(f.get("url") or "")):
90+
impact = min(5, impact + 1)
91+
reasons.append("a sensitive endpoint")
92+
return impact, "; ".join(reasons)
93+
94+
95+
def cell(lk: int, br: int) -> str:
96+
"""The risk cell for a likelihood and breachability."""
97+
return _TABLE.get(max(1, min(5, lk)), {}).get(max(1, min(5, br)), "low")
98+
99+
100+
def annotate(findings: list, exposure: str = "") -> list:
101+
"""Add likelihood, breachability, risk and a basis to each finding, in place."""
102+
for f in findings:
103+
lk, lk_why = likelihood(f)
104+
br, br_why = breachability(f, exposure)
105+
f["likelihood"] = lk
106+
f["breachability"] = br
107+
f["likelihood_label"] = LIKELIHOOD_LABEL[lk]
108+
f["breachability_label"] = IMPACT_LABEL[br]
109+
f["risk"] = cell(lk, br)
110+
f["risk_basis"] = (f"Likelihood {LIKELIHOOD_LABEL[lk]} ({lk_why}). "
111+
f"Impact {IMPACT_LABEL[br]} ({br_why}).")
112+
return findings
113+
114+
115+
def matrix(findings: list) -> dict:
116+
"""The 5x5 heatmap for the report, ready to render: rows run breachability
117+
5 (severe) down to 1, columns run likelihood 1 to 5. Each cell carries its
118+
finding count and its risk color."""
119+
count = {b: {l: 0 for l in range(1, 6)} for b in range(1, 6)}
120+
worst, order = "low", ["low", "medium", "high", "critical"]
121+
for f in findings:
122+
lk, br = f.get("likelihood"), f.get("breachability")
123+
if lk and br:
124+
count[br][lk] += 1
125+
if order.index(f.get("risk", "low")) > order.index(worst):
126+
worst = f.get("risk", "low")
127+
rows = [{"b": b, "label": IMPACT_LABEL[b],
128+
"cells": [{"l": l, "n": count[b][l], "risk": cell(l, b)}
129+
for l in range(1, 6)]}
130+
for b in range(5, 0, -1)]
131+
return {"rows": rows, "worst": worst,
132+
"columns": [LIKELIHOOD_LABEL[l] for l in range(1, 6)],
133+
"counts": {r: sum(1 for f in findings if f.get("risk") == r)
134+
for r in ("critical", "high", "medium", "low")}}

‎app/web/templates/dast_report_pdf.html‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,23 @@ <h3>2.3 OWASP Top 10 grades</h3>
167167
</table>
168168
<p class="faint">Each category is graded on its own findings. The overall grade caps any one category at 25 points.</p>
169169

170+
<h3>2.4 Risk matrix</h3>
171+
<p class="faint">{{ "Each finding placed by how likely it is real and reachable against what a successful exploit would yield, weighted by how exposed the site is. This is risk, not a compliance count." | sentence_spaced }}</p>
172+
<table class="heat">
173+
<thead><tr><th style="text-align:left">Impact \ Likelihood</th>
174+
{% for c in risk_matrix.columns %}<th class="c">{{ c }}</th>{% endfor %}</tr></thead>
175+
<tbody>
176+
{% for row in risk_matrix.rows %}
177+
<tr><td class="name">{{ row.label }}</td>
178+
{% for cell in row.cells %}
179+
<td class="{% if cell.n %}r-{{ cell.risk }}{% endif %}">{{ cell.n or '' }}</td>
180+
{% endfor %}</tr>
181+
{% endfor %}
182+
</tbody>
183+
</table>
184+
<p class="faint">Worst risk: <span class="badge r-{{ risk_matrix.worst }}">{{ risk_matrix.worst | upper }}</span>&nbsp;
185+
{{ risk_matrix.counts.critical }} critical, {{ risk_matrix.counts.high }} high, {{ risk_matrix.counts.medium }} medium, {{ risk_matrix.counts.low }} low by risk.</p>
186+
170187
<h2 class="new-page">3. Scope and method</h2>
171188
<h3>3.1 In scope</h3>
172189
<table>
@@ -223,6 +240,9 @@ <h3>{{ f.ref }} {{ f.title }}</h3>
223240
<tr><td class="k">Severity</td><td><span class="badge r-{{ f.severity }}">{{ f.severity | upper }}</span>
224241
{% if triaged and f.counted == 0 %} <span class="soft">Not counted: the enhanced review judged it a likely false positive.</span>
225242
{% elif triaged and f.counted < 1 %} <span class="soft">Counted at half: the enhanced review wants a person to check it.</span>{% endif %}</td></tr>
243+
{% if f.risk %}<tr><td class="k">Risk</td><td><span class="badge r-{{ f.risk }}">{{ f.risk | upper }}</span>
244+
&nbsp;Likelihood {{ f.likelihood_label }}, impact {{ f.breachability_label }}.
245+
{% if f.risk_basis %}<span class="soft">{{ f.risk_basis | sentence_spaced }}</span>{% endif %}</td></tr>{% endif %}
226246
<tr><td class="k">OWASP Top 10</td><td>{{ f.owasp }} {{ f.owasp_name }}</td></tr>
227247
{% if f.cwe %}<tr><td class="k">CWE</td><td>{{ f.cwe }}</td></tr>{% endif %}
228248
{% if f.url %}<tr><td class="k">Location</td><td class="mono">{{ f.url }}</td></tr>{% endif %}

0 commit comments

Comments
 (0)