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:
@@ -1,14 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { CRADemo, Measure } from '../_hooks/useCRADemo'
|
import { CRADemo, Measure } from '../_hooks/useCRADemo'
|
||||||
|
import { useCRAProgress } from '../_hooks/useCRAProgress'
|
||||||
import { ManagementSummary } from './ManagementSummary'
|
import { ManagementSummary } from './ManagementSummary'
|
||||||
|
import { ProgressView } from './ProgressView'
|
||||||
import { CyberSafetyHero } from './CyberSafetyHero'
|
import { CyberSafetyHero } from './CyberSafetyHero'
|
||||||
import { TechFindings } from './TechFindings'
|
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(
|
const measuresById: Record<string, Measure> = Object.fromEntries(
|
||||||
data.open_measures.map((m) => [m.id, m]),
|
data.open_measures.map((m) => [m.id, m]),
|
||||||
)
|
)
|
||||||
|
const { progress, live: progressLive } = useCRAProgress(scannerRepo, data.findings)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -27,6 +30,9 @@ export function CRACyberView({ data }: { data: CRADemo }) {
|
|||||||
{/* Ebene 1 — Management: Risiko & Aufwand auf einen Blick */}
|
{/* Ebene 1 — Management: Risiko & Aufwand auf einen Blick */}
|
||||||
<ManagementSummary data={data} measuresById={measuresById} />
|
<ManagementSummary data={data} measuresById={measuresById} />
|
||||||
|
|
||||||
|
{/* Ebene 1 — Projekt-Fortschritt: Ticket-Status zurückgelesen */}
|
||||||
|
<ProgressView progress={progress} live={progressLive} />
|
||||||
|
|
||||||
{/* Ebene 2 — Safety × Cyber: das Alleinstellungsmerkmal */}
|
{/* Ebene 2 — Safety × Cyber: das Alleinstellungsmerkmal */}
|
||||||
<CyberSafetyHero data={data} />
|
<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 & 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} />
|
<ScannerRepoPicker value={scannerRepo} onChange={setScannerRepo} />
|
||||||
<WeightsControl weights={weights} onChange={setWeights} />
|
<WeightsControl weights={weights} onChange={setWeights} />
|
||||||
<CRACyberView data={data} />
|
<CRACyberView data={data} scannerRepo={scannerRepo} />
|
||||||
<SnapshotPanel snapshots={snapshots} onSave={saveSnapshot} onView={viewSnapshot} />
|
<SnapshotPanel snapshots={snapshots} onSave={saveSnapshot} onView={viewSnapshot} />
|
||||||
</div>
|
</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,
|
||||||
|
}
|
||||||
@@ -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.vendor_assessment_routes import router as vendor_assessment_router
|
||||||
from compliance.api.cra_routes import router as cra_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_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.cra_link_routes import router as cra_link_router
|
||||||
from compliance.api.quaidal_routes import router as quaidal_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
|
# CRA (Cyber Resilience Act) Compliance
|
||||||
app.include_router(cra_router, prefix="/api")
|
app.include_router(cra_router, prefix="/api")
|
||||||
app.include_router(cra_assess_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(cra_link_router, prefix="/api")
|
||||||
app.include_router(quaidal_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"
|
||||||
Reference in New Issue
Block a user