feat(cra): Management-Fortschritts-Ansicht (Ticket-Status-Readback)

Liest den Lebenszyklus jedes Befunds (status + tracker_issue_url) aus dem
Scanner zurück und rollt ihn zu einem Management-Bild auf: % erledigt,
4-Phasen (offen/in Arbeit/erledigt/ausgeschlossen), offenes Restrisiko nach
Schweregrad, Fortschritt je CRA-Anforderung und eine Aufgaben-/Ticket-Tabelle
mit Jira-Link. Neuer Endpoint GET/POST /api/v1/cra/progress (dünn → Service
cra_progress, rein deterministisch, kein /assess-Schema-Drift). Frontend:
ProgressView in Ebene 1 (CRACyberView), live je Scanner-Repo, sonst Demo-Status.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-16 10:10:45 +02:00
parent 7a4f086151
commit 9e9d780902
8 changed files with 541 additions and 2 deletions
@@ -0,0 +1,39 @@
"""CRA project-progress endpoint (management view).
Reads each finding's lifecycle back from the scanner (status + tracker ticket)
and rolls it up into a completion picture: % done, what's left by risk, and
per-CRA-requirement coverage. Pull-flow (GET ?repo_id=) reads live from the
scanner's MCP; POST takes findings in the body (demo / direct).
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
from pydantic import BaseModel
from compliance.services.cra_progress import build_progress
from compliance.services.scanner_mcp_client import fetch_findings
router = APIRouter(prefix="/v1/cra", tags=["cra"])
class ProgressRequest(BaseModel):
# Raw finding dicts (scanner shape: status, tracker_issue_url, cwe, severity …).
findings: List[Dict[str, Any]] = []
@router.get("/progress")
async def progress(repo_id: Optional[str] = None, severity: Optional[str] = None):
"""Pull-flow: fetch the repo's findings from the scanner and roll up progress.
Returns an empty rollup if no scanner is configured."""
findings = await fetch_findings(repo_id=repo_id, severity=severity, limit=500)
result = build_progress(findings)
result["source"] = {"scanner": True, "pulled": len(findings), "repo_id": repo_id}
return result
@router.post("/progress")
async def progress_from_body(body: ProgressRequest):
"""Roll up progress for findings supplied directly (demo / offline)."""
result = build_progress(body.findings)
result["source"] = {"scanner": False, "pulled": len(body.findings)}
return result
@@ -0,0 +1,136 @@
"""Project-progress rollup for the CRA cyber findings (management view).
The dev workflow is: finding -> measure -> ticket (Jira) sent to engineering. The
scanner is the system of record for each finding's lifecycle: it carries
``status`` (open|triaged|resolved|false_positive|ignored) and, once a ticket is
created, ``tracker_issue_url``. We read that lifecycle back here and roll it up
into a management progress picture (% done, what's left, per-CRA-requirement
coverage) WITHOUT re-scanning.
Pure + deterministic: takes the raw scanner finding dicts, reuses the tested
mapper for the requirement/risk axis, and reads the status/ticket axis straight
off the raw dict. No DB, no LLM, no network.
"""
from compliance.api.cra_annex_i_data import ANNEX_I_REQUIREMENTS
from compliance.services.cra_finding_mapper import ScannerFinding, map_finding
_REQ_TITLE = {r["req_id"]: r.get("title", "") for r in ANNEX_I_REQUIREMENTS}
_RISK_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}
_RANK_RISK = {4: "CRITICAL", 3: "HIGH", 2: "MEDIUM", 1: "LOW", 0: ""}
# 4 management phases the headline bar is built from. Raw scanner status is kept
# separately (by_status) so nothing is hidden.
OFFEN, IN_ARBEIT, ERLEDIGT, AUSGESCHLOSSEN = "offen", "in_arbeit", "erledigt", "ausgeschlossen"
_PHASE_LABEL = {
OFFEN: "Offen",
IN_ARBEIT: "In Arbeit",
ERLEDIGT: "Erledigt",
AUSGESCHLOSSEN: "Ausgeschlossen",
}
_OPEN_PHASES = (OFFEN, IN_ARBEIT)
def _phase(status: str, has_ticket: bool) -> str:
"""Derive the management phase. A finding only counts as 'in Arbeit' once a
tracker ticket exists; 'erledigt' when the scanner reports it resolved."""
s = (status or "open").lower()
if s == "resolved":
return ERLEDIGT
if s in ("false_positive", "ignored"):
return AUSGESCHLOSSEN
if has_ticket:
return IN_ARBEIT
return OFFEN
def _new_req_agg(req_id: str, title: str) -> dict:
return {"req_id": req_id, "title": title, "total": 0,
OFFEN: 0, IN_ARBEIT: 0, ERLEDIGT: 0, AUSGESCHLOSSEN: 0, "_max_open_risk": 0}
def build_progress(raw_findings: list) -> dict:
"""Roll raw scanner findings up into a management progress picture."""
themes: list = []
by_phase = {OFFEN: 0, IN_ARBEIT: 0, ERLEDIGT: 0, AUSGESCHLOSSEN: 0}
by_status: dict = {}
by_risk_open = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
req_agg: dict = {}
for d in raw_findings:
if not isinstance(d, dict):
continue
sf = ScannerFinding.from_dict(d)
mf = map_finding(sf)
status = (d.get("status") or "open").lower()
tracker = d.get("tracker_issue_url") or ""
has_ticket = bool(tracker)
phase = _phase(status, has_ticket)
by_status[status] = by_status.get(status, 0) + 1
by_phase[phase] += 1
if phase in _OPEN_PHASES and mf.risk_level in by_risk_open:
by_risk_open[mf.risk_level] += 1
req = mf.primary_requirement or ""
rt = _REQ_TITLE.get(req, "")
themes.append({
"finding_id": mf.finding_id,
"title": mf.title or sf.title or mf.finding_id,
"requirement": req,
"requirement_title": rt,
"risk_level": mf.risk_level,
"phase": phase,
"phase_label": _PHASE_LABEL[phase],
"status": status,
"tracker_url": tracker,
"has_ticket": has_ticket,
"location": mf.location,
"updated_at": d.get("updated_at") or "",
})
agg = req_agg.setdefault(req, _new_req_agg(req, rt))
agg["total"] += 1
agg[phase] += 1
if phase in _OPEN_PHASES:
agg["_max_open_risk"] = max(agg["_max_open_risk"], _RISK_RANK.get(mf.risk_level, 0))
total = len(themes)
actionable = by_phase[OFFEN] + by_phase[IN_ARBEIT] + by_phase[ERLEDIGT]
completion_pct = round(100.0 * by_phase[ERLEDIGT] / actionable, 1) if actionable else 0.0
requirements = []
for agg in req_agg.values():
act = agg[OFFEN] + agg[IN_ARBEIT] + agg[ERLEDIGT]
if act > 0 and agg[ERLEDIGT] == act:
rphase = ERLEDIGT
elif agg[IN_ARBEIT] > 0 or agg[ERLEDIGT] > 0:
rphase = IN_ARBEIT
else:
rphase = OFFEN
requirements.append({
"req_id": agg["req_id"], "title": agg["title"], "total": agg["total"],
OFFEN: agg[OFFEN], IN_ARBEIT: agg[IN_ARBEIT], ERLEDIGT: agg[ERLEDIGT],
AUSGESCHLOSSEN: agg[AUSGESCHLOSSEN],
"phase": rphase, "phase_label": _PHASE_LABEL[rphase],
"completion_pct": round(100.0 * agg[ERLEDIGT] / act, 1) if act else 0.0,
"open_risk": _RANK_RISK[agg["_max_open_risk"]],
})
requirements.sort(key=lambda r: (-_RISK_RANK.get(r["open_risk"], 0), r["completion_pct"]))
# Surface the riskiest open work first in the theme table.
themes.sort(key=lambda t: (
0 if t["phase"] in _OPEN_PHASES else 1,
-_RISK_RANK.get(t["risk_level"], 0),
))
return {
"total": total,
"actionable": actionable,
"completion_pct": completion_pct,
"by_phase": by_phase,
"by_status": by_status,
"by_risk_open": by_risk_open,
"open_count": by_phase[OFFEN] + by_phase[IN_ARBEIT],
"requirements": requirements,
"themes": themes,
}
+2
View File
@@ -57,6 +57,7 @@ from compliance.api.agent_migration_routes import router as agent_migration_rout
from compliance.api.vendor_assessment_routes import router as vendor_assessment_router
from compliance.api.cra_routes import router as cra_router
from compliance.api.cra_assess_routes import router as cra_assess_router
from compliance.api.cra_progress_routes import router as cra_progress_router
from compliance.api.cra_link_routes import router as cra_link_router
from compliance.api.quaidal_routes import router as quaidal_router
@@ -174,6 +175,7 @@ app.include_router(vendor_assessment_router, prefix="/api")
# CRA (Cyber Resilience Act) Compliance
app.include_router(cra_router, prefix="/api")
app.include_router(cra_assess_router, prefix="/api")
app.include_router(cra_progress_router, prefix="/api")
app.include_router(cra_link_router, prefix="/api")
app.include_router(quaidal_router, prefix="/api")
@@ -0,0 +1,87 @@
"""Management progress rollup over scanner findings (status/ticket readback)."""
from compliance.services.cra_progress import build_progress
def _f(fid, cwe, status="open", tracker=None, severity="high"):
d = {"id": fid, "cwe": cwe, "severity": severity, "status": status}
if tracker:
d["tracker_issue_url"] = tracker
return d
class TestBuildProgress:
def test_empty(self):
r = build_progress([])
assert r["total"] == 0
assert r["completion_pct"] == 0.0
assert r["themes"] == [] and r["requirements"] == []
def test_phase_derivation(self):
findings = [
_f("a", "CWE-259", status="open"), # offen
_f("b", "CWE-319", status="triaged"), # offen (no ticket)
_f("c", "CWE-494", status="triaged", tracker="http://jira/1"), # in_arbeit
_f("d", "CWE-778", status="resolved"), # erledigt
_f("e", "CWE-1188", status="false_positive"), # ausgeschlossen
_f("g", "CWE-1104", status="ignored"), # ausgeschlossen
]
r = build_progress(findings)
assert r["by_phase"] == {"offen": 2, "in_arbeit": 1, "erledigt": 1, "ausgeschlossen": 2}
assert r["total"] == 6
# actionable excludes the 2 ausgeschlossen → 4; 1 done of 4 = 25%
assert r["actionable"] == 4
assert r["completion_pct"] == 25.0
assert r["open_count"] == 3 # offen + in_arbeit
def test_ticket_means_in_arbeit_even_when_open_status(self):
r = build_progress([_f("a", "CWE-259", status="open", tracker="http://jira/9")])
assert r["by_phase"]["in_arbeit"] == 1
assert r["themes"][0]["has_ticket"] is True
assert r["themes"][0]["tracker_url"] == "http://jira/9"
def test_requirement_rollup_and_completion(self):
# two findings on the same CRA requirement (CWE-259 → CRA-AI-8), one done
findings = [
_f("a", "CWE-259", status="resolved"),
_f("b", "CWE-259", status="open"),
]
r = build_progress(findings)
reqs = {x["req_id"]: x for x in r["requirements"]}
assert "CRA-AI-8" in reqs
ai8 = reqs["CRA-AI-8"]
assert ai8["total"] == 2 and ai8["erledigt"] == 1 and ai8["offen"] == 1
assert ai8["completion_pct"] == 50.0
assert ai8["phase"] == "in_arbeit"
def test_fully_resolved_requirement_is_erledigt(self):
r = build_progress([_f("a", "CWE-259", status="resolved")])
ai8 = next(x for x in r["requirements"] if x["req_id"] == "CRA-AI-8")
assert ai8["phase"] == "erledigt"
assert ai8["completion_pct"] == 100.0
assert r["completion_pct"] == 100.0
def test_open_risk_breakdown_excludes_done_and_excluded(self):
findings = [
_f("a", "CWE-259", status="open", severity="critical"),
_f("b", "CWE-319", status="resolved", severity="high"),
_f("c", "CWE-1188", status="false_positive", severity="high"),
]
r = build_progress(findings)
# only the open critical counts toward open risk
assert r["by_risk_open"]["CRITICAL"] == 1
assert sum(r["by_risk_open"].values()) == 1
def test_tolerates_scanner_shape(self):
# real scanner field names: scan_type, cvss_score, file_path, _id
findings = [{
"_id": {"$oid": "abc"}, "cwe": "CWE-319", "severity": "high",
"status": "triaged", "tracker_issue_url": None, "scan_type": "sast",
"file_path": "src/x.py", "updated_at": "2026-06-15T10:00:00Z",
}]
r = build_progress(findings)
assert r["total"] == 1
t = r["themes"][0]
assert t["finding_id"] == "abc"
assert t["phase"] == "offen" # triaged, no ticket
assert t["location"] == "src/x.py"
assert t["updated_at"] == "2026-06-15T10:00:00Z"