feat(cra): coarse priority engine — P0 floor + customer weights + quick wins

Deterministic prioritisation on top of the mapper (cra_prioritizer.py): a
non-negotiable P0 floor (safety-function compromise / actively exploited /
CRITICAL — customer weights cannot demote) plus a discretionary tier ranked by
severity x the customer's weight (high/medium/low) for the 5 business objectives
(access/data/network_api/supply_updates/monitoring). Quick-win flag (high impact,
low effort) for a second view; each finding carries a short priority reason.
Endpoint accepts weights + per-finding safety_impact/exploited. Rough pre-sort
only (devs re-sort in Jira). No DB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-14 08:21:56 +02:00
parent ad83b8dc67
commit 12fa179bfd
6 changed files with 211 additions and 8 deletions
@@ -9,7 +9,7 @@ Project-less by design: works standalone for ANY customer — including those wi
no CE risk assessment and no FMEA yet (the mandatory baseline). Reuses the fully
tested mapper; no DB, no LLM, no RAG. Same logic the MCP server exposes.
"""
from typing import List, Optional
from typing import Dict, List, Optional
from fastapi import APIRouter
from pydantic import BaseModel
@@ -28,13 +28,18 @@ class FindingIn(BaseModel):
severity: Optional[str] = ""
cvss: Optional[float] = None
location: Optional[str] = ""
safety_impact: Optional[bool] = False
exploited: Optional[bool] = False
class AssessRequest(BaseModel):
findings: List[FindingIn]
# customer priorities for the discretionary tier: {objective: high|medium|low}.
# objectives: access | data | network_api | supply_updates | monitoring.
weights: Optional[Dict[str, str]] = None
@router.post("/assess")
async def assess(body: AssessRequest):
payload = {"findings": [f.model_dump() for f in body.findings]}
payload = {"findings": [f.model_dump() for f in body.findings], "weights": body.weights}
return assess_findings_payload(payload)
@@ -15,6 +15,7 @@ from typing import Optional
from compliance.api.cra_annex_i_data import ANNEX_I_REQUIREMENTS, MEASURES, DEADLINES
from compliance.services.cra_security_crosswalk import security_refs_for
from compliance.services.cra_prioritizer import prioritize, OBJECTIVES
_REQ_INDEX = {r["req_id"]: r for r in ANNEX_I_REQUIREMENTS}
_SEV_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
@@ -66,6 +67,8 @@ class ScannerFinding:
severity: str = "" # critical | high | medium | low (scanner's rating)
cvss: Optional[float] = None
location: str = ""
safety_impact: bool = False # compromise can defeat a CE safety function (personal harm)
exploited: bool = False # actively / publicly exploited
@classmethod
def from_dict(cls, d: dict) -> "ScannerFinding":
@@ -78,6 +81,8 @@ class ScannerFinding:
severity=d.get("severity", "") or "",
cvss=d.get("cvss"),
location=d.get("location", "") or d.get("path", ""),
safety_impact=bool(d.get("safety_impact", False)),
exploited=bool(d.get("exploited", False)),
)
@@ -94,6 +99,14 @@ class MappedFinding:
owasp_refs: list = field(default_factory=list) # [{code, label}] OWASP Top 10:2021
rationale: str = ""
unmapped: bool = False
# carried from the finding + set by the prioritizer (cra_prioritizer.prioritize)
safety_impact: bool = False
exploited: bool = False
objective: str = ""
priority_tier: str = "" # P0 (non-negotiable floor) | P1 | P2 | P3
priority_score: int = 0
quick_win: bool = False
priority_reason: str = ""
@dataclass
@@ -105,6 +118,8 @@ class CRAAssessment:
open_measures: list = field(default_factory=list) # [{id, description}]
unmapped_findings: list = field(default_factory=list)
coverage_pct: float = 0.0
quick_wins: list = field(default_factory=list) # finding_ids: high impact, low effort
objectives: list = field(default_factory=lambda: list(OBJECTIVES))
deadlines: list = field(default_factory=lambda: list(DEADLINES))
@@ -156,7 +171,7 @@ def map_finding(f: ScannerFinding) -> MappedFinding:
return MappedFinding(
finding_id=f.id, risk_level=_SEV_BY_RANK.get(_rank(finding_sev), "LOW"),
rationale="Kein eindeutiger CRA-Anforderungsbezug erkannt — manuelle Pruefung.",
unmapped=True,
unmapped=True, safety_impact=f.safety_impact, exploited=f.exploited,
)
primary = _REQ_INDEX[reqs[0]]
risk_rank = max(_rank(finding_sev), _rank(primary["severity"]))
@@ -177,12 +192,18 @@ def map_finding(f: ScannerFinding) -> MappedFinding:
nist_refs=refs["nist"],
owasp_refs=refs["owasp"],
rationale="{}: {}".format(primary["req_id"], primary.get("title", "")),
safety_impact=f.safety_impact,
exploited=f.exploited,
)
def assess_findings(findings: list) -> CRAAssessment:
"""Map a list of ScannerFinding into a deterministic CRA assessment."""
mapped = [map_finding(f) for f in findings]
def assess_findings(findings: list, weights=None) -> CRAAssessment:
"""Map findings to a deterministic CRA assessment, then prioritise them.
weights: {objective: 'high'|'medium'|'low'} — the customer's priorities for
the discretionary tier (the P0 floor ignores them).
"""
mapped = prioritize([map_finding(f) for f in findings], weights)
by_risk = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
reqs_touched, measure_ids, unmapped = set(), [], []
for m in mapped:
@@ -204,6 +225,7 @@ def assess_findings(findings: list) -> CRAAssessment:
open_measures=[{"id": mid, "description": MEASURES.get(mid, "")} for mid in measure_ids],
unmapped_findings=unmapped,
coverage_pct=round(100.0 * covered / total, 1) if total else 0.0,
quick_wins=[m.finding_id for m in mapped if m.quick_win],
)
@@ -213,6 +235,7 @@ def assess_findings_payload(payload: dict) -> dict:
This is the testable tool body the MCP server wraps (kept transport-free).
"""
raw = payload.get("findings", []) if isinstance(payload, dict) else []
weights = payload.get("weights") if isinstance(payload, dict) else None
findings = [ScannerFinding.from_dict(d) for d in raw]
assessment = assess_findings(findings)
assessment = assess_findings(findings, weights)
return asdict(assessment) # recurses into nested MappedFinding dataclasses
@@ -0,0 +1,99 @@
"""Coarse, deterministic CRA finding prioritisation.
Two tiers (the customer's developers re-sort in Jira anyway — we only pre-sort):
- P0 floor (NON-negotiable, customer weights cannot demote): a finding that can
defeat a CE safety function (personal harm), is actively exploited, or is
CRITICAL severity.
- Discretionary: ranked by severity x the customer's weight for the business
objective the finding belongs to.
Plus a Quick-Win flag (high impact, low effort) for a second view.
No DB/LLM. Operates on mapped-finding objects by attribute (duck-typed) to avoid
a circular import with the mapper. Every priority carries a short reason.
"""
from compliance.api.cra_annex_i_data import ANNEX_I_REQUIREMENTS, SEVERITY_WEIGHT
_REQ = {r["req_id"]: r for r in ANNEX_I_REQUIREMENTS}
# 8 CRA-AI categories -> 5 business objectives the customer can weight.
_CATEGORY_TO_OBJECTIVE = {
"Secure-by-Design": "network_api",
"Authentifizierung": "access",
"Kryptografie": "data",
"SSDLC": "supply_updates",
"Supply Chain": "supply_updates",
"Updates": "supply_updates",
"Logging": "monitoring",
"Vulnerability Handling": "monitoring",
}
OBJECTIVES = ["access", "data", "network_api", "supply_updates", "monitoring"]
OBJECTIVE_LABELS = {
"access": "Zugang/Authentifizierung",
"data": "Datenvertraulichkeit",
"network_api": "Netzwerk/API",
"supply_updates": "Updates/Supply-Chain",
"monitoring": "Monitoring/Incident",
}
_WEIGHT_NUM = {"high": 3, "medium": 2, "low": 1}
_WEIGHT_LABEL = {"high": "hoch", "medium": "mittel", "low": "niedrig"}
_QUICK_WIN_MAX_EFFORT = 3
_TIER_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
def objective_for(req_id: str) -> str:
req = _REQ.get(req_id)
if not req:
return "network_api"
return _CATEGORY_TO_OBJECTIVE.get(req.get("category", ""), "network_api")
def _reason(p0, safety, exploited, sev, objective, weight_key, quick_win, tier):
if p0:
if safety:
return "P0 — kann eine Sicherheitsfunktion der CE-Risikobeurteilung aushebeln (Personenschaden)"
if exploited:
return "P0 — aktiv ausnutzbar / oeffentlich bekannt"
return "P0 — kritische Schwere"
obj = OBJECTIVE_LABELS.get(objective, objective)
base = "{} — Schwere {} + Prioritaet '{}' ({})".format(
tier, sev, obj, _WEIGHT_LABEL.get(weight_key, "mittel"))
if quick_win:
return base + " · Quick Win (geringer Aufwand)"
return base
def prioritize(mapped: list, weights=None) -> list:
"""Set priority fields on each mapped finding and return them sorted.
mapped: objects with attrs primary_requirement, risk_level, safety_impact,
exploited. weights: {objective: 'high'|'medium'|'low'}.
"""
weights = weights or {}
for m in mapped:
rid = getattr(m, "primary_requirement", "")
req = _REQ.get(rid, {})
objective = objective_for(rid)
sev = (getattr(m, "risk_level", "LOW") or "LOW").upper()
sev_w = SEVERITY_WEIGHT.get(sev, 10)
weight_key = str(weights.get(objective, "medium")).lower()
w = _WEIGHT_NUM.get(weight_key, 2)
safety = bool(getattr(m, "safety_impact", False))
exploited = bool(getattr(m, "exploited", False))
effort = req.get("effort_days", 99)
p0 = safety or exploited or sev == "CRITICAL"
score = sev_w * w
quick_win = (not p0) and effort <= _QUICK_WIN_MAX_EFFORT and sev in ("HIGH", "CRITICAL")
if p0:
tier = "P0"
elif score >= 150:
tier = "P1"
elif score >= 60:
tier = "P2"
else:
tier = "P3"
m.objective = objective
m.priority_tier = tier
m.priority_score = score
m.quick_win = quick_win
m.priority_reason = _reason(p0, safety, exploited, sev, objective, weight_key, quick_win, tier)
return sorted(mapped, key=lambda x: (_TIER_RANK.get(x.priority_tier, 9), -x.priority_score))
@@ -38,3 +38,26 @@ def test_assess_requires_finding_id():
# id is required by the schema -> 422
r = client.post("/api/v1/cra/assess", json={"findings": [{"title": "no id"}]})
assert r.status_code == 422
def test_assess_prioritizes_with_weights():
r = client.post("/api/v1/cra/assess", json={
"findings": [
{"id": "mfa", "cwe": "CWE-306", "severity": "high"},
{"id": "log", "cwe": "CWE-778", "severity": "high"},
],
"weights": {"access": "high", "monitoring": "low"},
})
assert r.status_code == 200
d = r.json()
order = [m["finding_id"] for m in d["mapped"]]
assert order.index("mfa") < order.index("log")
assert all("priority_tier" in m for m in d["mapped"])
def test_assess_p0_floor_on_safety_impact():
r = client.post("/api/v1/cra/assess", json={"findings": [
{"id": "s", "cwe": "CWE-319", "severity": "low", "safety_impact": True},
]})
assert r.status_code == 200
assert r.json()["mapped"][0]["priority_tier"] == "P0"
@@ -77,7 +77,8 @@ def test_payload_entry_is_json_serializable_and_deterministic():
assert r1 == r2 # deterministic
assert r1["findings_total"] == 2
assert isinstance(r1["mapped"], list) and isinstance(r1["mapped"][0], dict)
assert r1["mapped"][0]["primary_requirement"] == "CRA-AI-9"
by_id = {m["finding_id"]: m for m in r1["mapped"]} # order is now priority-sorted
assert by_id["x"]["primary_requirement"] == "CRA-AI-9"
def test_empty_payload_is_safe():
@@ -0,0 +1,52 @@
"""Tests for the coarse CRA prioritisation (P0 floor + weighted tier + quick wins)."""
from compliance.services.cra_finding_mapper import ScannerFinding, assess_findings
def test_safety_impact_forces_p0():
a = assess_findings([ScannerFinding(id="s", title="TLS 1.0", cwe="CWE-319", severity="medium", safety_impact=True)])
m = a.mapped[0]
assert m.priority_tier == "P0"
assert "Personenschaden" in m.priority_reason
def test_exploited_forces_p0():
a = assess_findings([ScannerFinding(id="e", title="outdated dep", category="dependency", severity="medium", exploited=True)])
assert a.mapped[0].priority_tier == "P0"
def test_critical_is_p0():
a = assess_findings([ScannerFinding(id="c", title="default password", cwe="CWE-259", severity="critical")])
assert a.mapped[0].priority_tier == "P0"
def test_weights_order_the_discretionary_tier():
findings = [
ScannerFinding(id="log", title="no security logging", cwe="CWE-778", severity="high"), # monitoring
ScannerFinding(id="mfa", title="missing authentication", cwe="CWE-306", severity="high"), # access
]
a = assess_findings(findings, weights={"access": "high", "monitoring": "low"})
order = [m.finding_id for m in a.mapped]
assert order.index("mfa") < order.index("log")
assert a.mapped[0].priority_tier != "P0" # neither is a floor finding
def test_quick_win_flag_and_view():
a = assess_findings([ScannerFinding(id="tls", title="TLS 1.0", cwe="CWE-319", severity="high")])
m = a.mapped[0]
assert m.primary_requirement == "CRA-AI-15" # effort 2 days
assert m.quick_win is True
assert "tls" in a.quick_wins
def test_p0_sorts_above_discretionary():
findings = [
ScannerFinding(id="low", title="missing logging", cwe="CWE-778", severity="low"), # P3
ScannerFinding(id="crit", title="default password", cwe="CWE-259", severity="critical"), # P0
]
a = assess_findings(findings)
assert a.mapped[0].finding_id == "crit"
def test_objectives_exposed():
a = assess_findings([])
assert a.objectives == ["access", "data", "network_api", "supply_updates", "monitoring"]