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
@@ -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<string, Measure> = Object.fromEntries(
data.open_measures.map((m) => [m.id, m]),
)
const { progress, live: progressLive } = useCRAProgress(scannerRepo, data.findings)
return (
<div className="space-y-8">
@@ -27,6 +30,9 @@ export function CRACyberView({ data }: { data: CRADemo }) {
{/* Ebene 1 — Management: Risiko & Aufwand auf einen Blick */}
<ManagementSummary data={data} measuresById={measuresById} />
{/* Ebene 1 — Projekt-Fortschritt: Ticket-Status zurückgelesen */}
<ProgressView progress={progress} live={progressLive} />
{/* Ebene 2 — Safety × Cyber: das Alleinstellungsmerkmal */}
<CyberSafetyHero data={data} />
@@ -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<string, string> = {
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<string, string> = {
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 (
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${PHASE_BADGE[phase] || PHASE_BADGE.offen}`}>
{label}
</span>
)
}
// 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 (
<div className="flex h-3 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className={PHASE_BAR.erledigt} style={{ width: seg(p.by_phase.erledigt || 0) }} title={`Erledigt: ${p.by_phase.erledigt || 0}`} />
<div className={PHASE_BAR.in_arbeit} style={{ width: seg(p.by_phase.in_arbeit || 0) }} title={`In Arbeit: ${p.by_phase.in_arbeit || 0}`} />
<div className={PHASE_BAR.offen} style={{ width: seg(p.by_phase.offen || 0) }} title={`Offen: ${p.by_phase.offen || 0}`} />
</div>
)
}
function ReqRow({ r }: { r: ProgressReq }) {
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1 py-2 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{r.title || r.req_id}</span>
<PhaseBadge phase={r.phase} label={r.phase_label} />
{r.open_risk && (
<span className={`inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold ${RISK_BADGE[r.open_risk]}`}>
{RISK_LABEL[r.open_risk]} offen
</span>
)}
</div>
<div className="mt-1 flex h-2 w-full max-w-md overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className="bg-emerald-500" style={{ width: `${r.completion_pct}%` }} />
</div>
</div>
<div className="text-right text-sm text-gray-500 whitespace-nowrap">
{r.erledigt}/{r.offen + r.in_arbeit + r.erledigt} erledigt
</div>
</div>
)
}
function ThemeRow({ t }: { t: ProgressTheme }) {
return (
<tr className="border-b border-gray-100 dark:border-gray-700/50 align-top">
<td className="py-2.5 px-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{t.title}</div>
{t.location && <div className="text-xs text-gray-400">{t.location}</div>}
</td>
<td className="py-2.5 px-3 text-sm text-gray-700 dark:text-gray-200">{t.requirement_title || t.requirement}</td>
<td className="py-2.5 px-3">
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${RISK_BADGE[t.risk_level] || RISK_BADGE.LOW}`}>
{RISK_LABEL[t.risk_level] || t.risk_level}
</span>
</td>
<td className="py-2.5 px-3"><PhaseBadge phase={t.phase} label={t.phase_label} /></td>
<td className="py-2.5 px-3 text-sm">
{t.has_ticket ? (
<a href={t.tracker_url} target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:underline">
Ticket öffnen
</a>
) : (
<span className="text-gray-400">kein Ticket</span>
)}
</td>
</tr>
)
}
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 (
<section className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Projekt-Fortschritt</h3>
<p className="text-sm text-gray-500">
Status je Befund zurückgelesen aus den Tickets der Entwicklung.
</p>
</div>
<span className={`text-xs rounded px-2 py-1 ${live ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'}`}>
{live ? `Live · Repo ${repo ? repo.slice(0, 8) : ''}` : 'Demo-Status'}
</span>
</div>
{/* Headline completion */}
<div className="grid gap-4 md:grid-cols-[auto_1fr] items-center">
<div className="text-center md:text-left">
<p className="text-5xl font-bold text-emerald-600 dark:text-emerald-400 leading-none">{p.completion_pct}%</p>
<p className="text-sm text-gray-500 mt-1">erledigt ({p.by_phase.erledigt || 0} von {p.actionable})</p>
</div>
<div className="space-y-2">
<CompletionBar p={p} />
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span className="text-emerald-600 dark:text-emerald-400"> {p.by_phase.erledigt || 0} Erledigt</span>
<span className="text-blue-600 dark:text-blue-400"> {p.by_phase.in_arbeit || 0} In Arbeit</span>
<span className="text-gray-500"> {p.by_phase.offen || 0} Offen</span>
{p.by_phase.ausgeschlossen ? (
<span className="text-slate-400"> {p.by_phase.ausgeschlossen} Ausgeschlossen</span>
) : null}
</div>
</div>
</div>
{/* What's left, by risk */}
{p.open_count > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-300">Noch offen:</span>
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map((lvl) =>
p.by_risk_open[lvl] ? (
<span key={lvl} className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-sm font-semibold ${RISK_BADGE[lvl]}`}>
{p.by_risk_open[lvl]} {RISK_LABEL[lvl]}
</span>
) : null,
)}
</div>
)}
{/* Per-requirement progress */}
{p.requirements.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-1">Fortschritt je Anforderung</h4>
<div>
{p.requirements.map((r) => <ReqRow key={r.req_id} r={r} />)}
</div>
</div>
)}
{/* Tasks & tickets */}
<div className="overflow-x-auto">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-1">Aufgaben &amp; Tickets</h4>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700 text-left">
<th className="py-2 px-3">Befund</th>
<th className="py-2 px-3">Anforderung</th>
<th className="py-2 px-3">Risiko</th>
<th className="py-2 px-3">Status</th>
<th className="py-2 px-3">Ticket</th>
</tr>
</thead>
<tbody>
{p.themes.map((t) => <ThemeRow key={t.finding_id} t={t} />)}
</tbody>
</table>
</div>
</section>
)
}
@@ -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<string, number>
by_status: Record<string, number>
by_risk_open: Record<string, number>
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<string, { status: string; tracker?: string }> = {
'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<CRAProgress | null>(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 }
}
@@ -31,7 +31,7 @@ export default function CRAPage() {
)}
<ScannerRepoPicker value={scannerRepo} onChange={setScannerRepo} />
<WeightsControl weights={weights} onChange={setWeights} />
<CRACyberView data={data} />
<CRACyberView data={data} scannerRepo={scannerRepo} />
<SnapshotPanel snapshots={snapshots} onSave={saveSnapshot} onView={viewSnapshot} />
</div>
)
@@ -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"