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:
@@ -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
|
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.
|
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 fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -28,13 +28,18 @@ class FindingIn(BaseModel):
|
|||||||
severity: Optional[str] = ""
|
severity: Optional[str] = ""
|
||||||
cvss: Optional[float] = None
|
cvss: Optional[float] = None
|
||||||
location: Optional[str] = ""
|
location: Optional[str] = ""
|
||||||
|
safety_impact: Optional[bool] = False
|
||||||
|
exploited: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
class AssessRequest(BaseModel):
|
class AssessRequest(BaseModel):
|
||||||
findings: List[FindingIn]
|
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")
|
@router.post("/assess")
|
||||||
async def assess(body: AssessRequest):
|
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)
|
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.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_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}
|
_REQ_INDEX = {r["req_id"]: r for r in ANNEX_I_REQUIREMENTS}
|
||||||
_SEV_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
_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)
|
severity: str = "" # critical | high | medium | low (scanner's rating)
|
||||||
cvss: Optional[float] = None
|
cvss: Optional[float] = None
|
||||||
location: str = ""
|
location: str = ""
|
||||||
|
safety_impact: bool = False # compromise can defeat a CE safety function (personal harm)
|
||||||
|
exploited: bool = False # actively / publicly exploited
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict) -> "ScannerFinding":
|
def from_dict(cls, d: dict) -> "ScannerFinding":
|
||||||
@@ -78,6 +81,8 @@ class ScannerFinding:
|
|||||||
severity=d.get("severity", "") or "",
|
severity=d.get("severity", "") or "",
|
||||||
cvss=d.get("cvss"),
|
cvss=d.get("cvss"),
|
||||||
location=d.get("location", "") or d.get("path", ""),
|
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
|
owasp_refs: list = field(default_factory=list) # [{code, label}] OWASP Top 10:2021
|
||||||
rationale: str = ""
|
rationale: str = ""
|
||||||
unmapped: bool = False
|
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
|
@dataclass
|
||||||
@@ -105,6 +118,8 @@ class CRAAssessment:
|
|||||||
open_measures: list = field(default_factory=list) # [{id, description}]
|
open_measures: list = field(default_factory=list) # [{id, description}]
|
||||||
unmapped_findings: list = field(default_factory=list)
|
unmapped_findings: list = field(default_factory=list)
|
||||||
coverage_pct: float = 0.0
|
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))
|
deadlines: list = field(default_factory=lambda: list(DEADLINES))
|
||||||
|
|
||||||
|
|
||||||
@@ -156,7 +171,7 @@ def map_finding(f: ScannerFinding) -> MappedFinding:
|
|||||||
return MappedFinding(
|
return MappedFinding(
|
||||||
finding_id=f.id, risk_level=_SEV_BY_RANK.get(_rank(finding_sev), "LOW"),
|
finding_id=f.id, risk_level=_SEV_BY_RANK.get(_rank(finding_sev), "LOW"),
|
||||||
rationale="Kein eindeutiger CRA-Anforderungsbezug erkannt — manuelle Pruefung.",
|
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]]
|
primary = _REQ_INDEX[reqs[0]]
|
||||||
risk_rank = max(_rank(finding_sev), _rank(primary["severity"]))
|
risk_rank = max(_rank(finding_sev), _rank(primary["severity"]))
|
||||||
@@ -177,12 +192,18 @@ def map_finding(f: ScannerFinding) -> MappedFinding:
|
|||||||
nist_refs=refs["nist"],
|
nist_refs=refs["nist"],
|
||||||
owasp_refs=refs["owasp"],
|
owasp_refs=refs["owasp"],
|
||||||
rationale="{}: {}".format(primary["req_id"], primary.get("title", "")),
|
rationale="{}: {}".format(primary["req_id"], primary.get("title", "")),
|
||||||
|
safety_impact=f.safety_impact,
|
||||||
|
exploited=f.exploited,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_findings(findings: list) -> CRAAssessment:
|
def assess_findings(findings: list, weights=None) -> CRAAssessment:
|
||||||
"""Map a list of ScannerFinding into a deterministic CRA assessment."""
|
"""Map findings to a deterministic CRA assessment, then prioritise them.
|
||||||
mapped = [map_finding(f) for f in findings]
|
|
||||||
|
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}
|
by_risk = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
||||||
reqs_touched, measure_ids, unmapped = set(), [], []
|
reqs_touched, measure_ids, unmapped = set(), [], []
|
||||||
for m in mapped:
|
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],
|
open_measures=[{"id": mid, "description": MEASURES.get(mid, "")} for mid in measure_ids],
|
||||||
unmapped_findings=unmapped,
|
unmapped_findings=unmapped,
|
||||||
coverage_pct=round(100.0 * covered / total, 1) if total else 0.0,
|
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).
|
This is the testable tool body the MCP server wraps (kept transport-free).
|
||||||
"""
|
"""
|
||||||
raw = payload.get("findings", []) if isinstance(payload, dict) else []
|
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]
|
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
|
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
|
# id is required by the schema -> 422
|
||||||
r = client.post("/api/v1/cra/assess", json={"findings": [{"title": "no id"}]})
|
r = client.post("/api/v1/cra/assess", json={"findings": [{"title": "no id"}]})
|
||||||
assert r.status_code == 422
|
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 == r2 # deterministic
|
||||||
assert r1["findings_total"] == 2
|
assert r1["findings_total"] == 2
|
||||||
assert isinstance(r1["mapped"], list) and isinstance(r1["mapped"][0], dict)
|
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():
|
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"]
|
||||||
Reference in New Issue
Block a user