|
| 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")}} |
0 commit comments