diff --git a/admin-compliance/app/sdk/iace/[projectId]/cra/_components/CRACyberView.tsx b/admin-compliance/app/sdk/iace/[projectId]/cra/_components/CRACyberView.tsx index ac8fffad..95c98a96 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/cra/_components/CRACyberView.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/cra/_components/CRACyberView.tsx @@ -1,14 +1,17 @@ 'use client' import { CRADemo, Measure } from '../_hooks/useCRADemo' +import { useCRAProgress } from '../_hooks/useCRAProgress' import { ManagementSummary } from './ManagementSummary' +import { ProgressView } from './ProgressView' import { CyberSafetyHero } from './CyberSafetyHero' import { TechFindings } from './TechFindings' -export function CRACyberView({ data }: { data: CRADemo }) { +export function CRACyberView({ data, scannerRepo = '' }: { data: CRADemo; scannerRepo?: string }) { const measuresById: Record = Object.fromEntries( data.open_measures.map((m) => [m.id, m]), ) + const { progress, live: progressLive } = useCRAProgress(scannerRepo, data.findings) return (
@@ -27,6 +30,9 @@ export function CRACyberView({ data }: { data: CRADemo }) { {/* Ebene 1 — Management: Risiko & Aufwand auf einen Blick */} + {/* Ebene 1 — Projekt-Fortschritt: Ticket-Status zurückgelesen */} + + {/* Ebene 2 — Safety × Cyber: das Alleinstellungsmerkmal */} diff --git a/admin-compliance/app/sdk/iace/[projectId]/cra/_components/ProgressView.tsx b/admin-compliance/app/sdk/iace/[projectId]/cra/_components/ProgressView.tsx new file mode 100644 index 00000000..e8808fee --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/cra/_components/ProgressView.tsx @@ -0,0 +1,172 @@ +'use client' + +import { CRAProgress, ProgressReq, ProgressTheme } from '../_hooks/useCRAProgress' +import { RISK_BADGE, RISK_LABEL } from './cra-badges' + +const PHASE_BADGE: Record = { + offen: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300', + in_arbeit: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + erledigt: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + ausgeschlossen: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400', +} +const PHASE_BAR: Record = { + offen: 'bg-gray-300 dark:bg-gray-600', + in_arbeit: 'bg-blue-500', + erledigt: 'bg-emerald-500', +} + +function PhaseBadge({ phase, label }: { phase: string; label: string }) { + return ( + + {label} + + ) +} + +// Segmented completion bar over the actionable findings (excl. ausgeschlossen). +function CompletionBar({ p }: { p: CRAProgress }) { + const base = Math.max(1, p.actionable) + const seg = (n: number) => `${(100 * n) / base}%` + return ( +
+
+
+
+
+ ) +} + +function ReqRow({ r }: { r: ProgressReq }) { + return ( +
+
+
+ {r.title || r.req_id} + + {r.open_risk && ( + + {RISK_LABEL[r.open_risk]} offen + + )} +
+
+
+
+
+
+ {r.erledigt}/{r.offen + r.in_arbeit + r.erledigt} erledigt +
+
+ ) +} + +function ThemeRow({ t }: { t: ProgressTheme }) { + return ( + + +
{t.title}
+ {t.location &&
{t.location}
} + + {t.requirement_title || t.requirement} + + + {RISK_LABEL[t.risk_level] || t.risk_level} + + + + + {t.has_ticket ? ( + + Ticket öffnen ↗ + + ) : ( + kein Ticket + )} + + + ) +} + +export function ProgressView({ progress, live }: { progress: CRAProgress | null; live: boolean }) { + if (!progress || progress.total === 0) return null + const p = progress + const repo = p.source?.repo_id + + return ( +
+
+
+

Projekt-Fortschritt

+

+ Status je Befund — zurückgelesen aus den Tickets der Entwicklung. +

+
+ + {live ? `Live · Repo ${repo ? repo.slice(0, 8) : ''}` : 'Demo-Status'} + +
+ + {/* Headline completion */} +
+
+

{p.completion_pct}%

+

erledigt ({p.by_phase.erledigt || 0} von {p.actionable})

+
+
+ +
+ ● {p.by_phase.erledigt || 0} Erledigt + ● {p.by_phase.in_arbeit || 0} In Arbeit + ● {p.by_phase.offen || 0} Offen + {p.by_phase.ausgeschlossen ? ( + ● {p.by_phase.ausgeschlossen} Ausgeschlossen + ) : null} +
+
+
+ + {/* What's left, by risk */} + {p.open_count > 0 && ( +
+ Noch offen: + {(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map((lvl) => + p.by_risk_open[lvl] ? ( + + {p.by_risk_open[lvl]} {RISK_LABEL[lvl]} + + ) : null, + )} +
+ )} + + {/* Per-requirement progress */} + {p.requirements.length > 0 && ( +
+

Fortschritt je Anforderung

+
+ {p.requirements.map((r) => )} +
+
+ )} + + {/* Tasks & tickets */} +
+

Aufgaben & Tickets

+ + + + + + + + + + + + {p.themes.map((t) => )} + +
BefundAnforderungRisikoStatusTicket
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRAProgress.ts b/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRAProgress.ts new file mode 100644 index 00000000..e975f9cb --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRAProgress.ts @@ -0,0 +1,97 @@ +'use client' + +import { useEffect, useState } from 'react' +import { CRAFinding } from './useCRADemo' + +// Management progress: reads each finding's lifecycle (status + Jira ticket) back +// from the scanner and rolls it up. Live for a linked scanner repo; for the demo +// (no repo) it POSTs the demo findings with a sample status mix so the layout is +// illustrative. Mirrors the backend compliance/services/cra_progress.py phases. + +export interface ProgressTheme { + finding_id: string + title: string + requirement: string + requirement_title: string + risk_level: string + phase: string + phase_label: string + status: string + tracker_url: string + has_ticket: boolean + location: string + updated_at: string +} + +export interface ProgressReq { + req_id: string + title: string + total: number + offen: number + in_arbeit: number + erledigt: number + ausgeschlossen: number + phase: string + phase_label: string + completion_pct: number + open_risk: string +} + +export interface CRAProgress { + total: number + actionable: number + completion_pct: number + by_phase: Record + by_status: Record + by_risk_open: Record + open_count: number + requirements: ProgressReq[] + themes: ProgressTheme[] + source?: { scanner: boolean; pulled: number; repo_id?: string } +} + +// Demo status mix (no real repo): one fixed, two ticketed/in-progress, rest open. +const DEMO_STATUS: Record = { + 'KH-CY-6': { status: 'resolved' }, + 'KH-CY-4': { status: 'triaged', tracker: 'https://jira.example.com/browse/BP-204' }, + 'KH-CY-3': { status: 'triaged', tracker: 'https://jira.example.com/browse/BP-203' }, +} + +export function useCRAProgress(scannerRepo: string, demoFindings: CRAFinding[]) { + const [progress, setProgress] = useState(null) + const [live, setLive] = useState(false) + + useEffect(() => { + let cancelled = false + ;(async () => { + if (scannerRepo) { + try { + const r = await fetch(`/api/v1/cra/progress?repo_id=${encodeURIComponent(scannerRepo)}`) + if (r.ok) { + const j = await r.json() + if (!cancelled) { setProgress(j); setLive(true) } + return + } + } catch { /* fall through to demo */ } + } + try { + const findings = demoFindings.map((f) => ({ + id: f.id, cwe: f.cwe, severity: f.scanner_severity, title: f.title, + status: DEMO_STATUS[f.id]?.status || 'open', + tracker_issue_url: DEMO_STATUS[f.id]?.tracker || null, + })) + const r = await fetch('/api/v1/cra/progress', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ findings }), + }) + if (r.ok) { + const j = await r.json() + if (!cancelled) { setProgress(j); setLive(false) } + } + } catch { /* leave null → view hides */ } + })() + return () => { cancelled = true } + }, [scannerRepo, demoFindings]) + + return { progress, live } +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/cra/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/cra/page.tsx index f84172aa..61c0b968 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/cra/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/cra/page.tsx @@ -31,7 +31,7 @@ export default function CRAPage() { )} - +
) diff --git a/backend-compliance/compliance/api/cra_progress_routes.py b/backend-compliance/compliance/api/cra_progress_routes.py new file mode 100644 index 00000000..6b3e9307 --- /dev/null +++ b/backend-compliance/compliance/api/cra_progress_routes.py @@ -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 diff --git a/backend-compliance/compliance/services/cra_progress.py b/backend-compliance/compliance/services/cra_progress.py new file mode 100644 index 00000000..592c7f90 --- /dev/null +++ b/backend-compliance/compliance/services/cra_progress.py @@ -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, + } diff --git a/backend-compliance/main.py b/backend-compliance/main.py index 40551645..fd8a62ac 100644 --- a/backend-compliance/main.py +++ b/backend-compliance/main.py @@ -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") diff --git a/backend-compliance/tests/test_cra_progress.py b/backend-compliance/tests/test_cra_progress.py new file mode 100644 index 00000000..76b7ffe5 --- /dev/null +++ b/backend-compliance/tests/test_cra_progress.py @@ -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"