575644c9c5
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m48s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Email-Hardening (mc_scorecard.top_fails):
Neue _is_hard_finding-Heuristik filtert konditionale MCs ohne
Negativ-Beleg aus den Top-Auffaelligkeiten. matched_text leer + Label
enthaelt "falls/sofern/wenn/soweit/ggf." -> raus, landet nur noch im
MC-Audit als "selbst pruefen". DATA-2066-A05 (kostenfreie Abschaltung
Standortdaten) ist das prototypische Beispiel.
MC-Audit-Frontend (audit/[checkId]/page.tsx):
Severity-Spalte (CRITICAL/HIGH/MEDIUM/LOW) entfernt — der MC-Audit
ist eine Checkliste, keine Severity-Drohung. Stattdessen:
- Spalte "Prioritaet" mit 3-Tier aus regulation-Mapping:
Gesetz (DSGVO/ePrivacy/TDDDG/...) / Behoerden-Leitlinie
(EDPB/DSK/EuGH/...) / Best-Practice (ISO/NIST/BSI)
- 3-Status: erfuellt (✓) / nicht erfuellt (✗) / selbst pruefen (?)
/ nicht anwendbar (—). rowReviewStatus() leitet "selbst pruefen"
aus matched_text-leer + konditionalem Label ab.
- Filter umgebaut auf 5 Stati statt 4
- Default-Filter "Nicht erfuellt" (vorher "Nur Fail")
Bonus: f.payload.risk_label TS-Cast im FindingsTab clean gemacht
(unknown -> string).
Effekt:
- Email an die GF zeigt nur noch echte Belege ("DSB fehlt",
"Gebuehr fuer Widerruf")
- MC-Audit ist eine sachliche Pruefliste fuer den Compliance-Officer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react'
|
|
|
|
type Finding = {
|
|
id: number
|
|
source_type: string
|
|
doc_type: string
|
|
severity: string
|
|
status: string
|
|
regulation: string
|
|
label: string
|
|
hint: string
|
|
action_recipe: Record<string, string>
|
|
anchor_excerpt: string
|
|
anchor_conf: number
|
|
vendor_name: string
|
|
category: string
|
|
payload: Record<string, unknown>
|
|
}
|
|
|
|
type Summary = {
|
|
total: number
|
|
by_source: Record<string, number>
|
|
by_severity: Record<string, number>
|
|
by_status: Record<string, number>
|
|
by_doc_type: Record<string, number>
|
|
}
|
|
|
|
type Resp = {
|
|
found: boolean
|
|
summary: Summary
|
|
count: number
|
|
findings: Finding[]
|
|
}
|
|
|
|
const SOURCE_LABEL: Record<string, string> = {
|
|
all: 'Alle Quellen',
|
|
mc: 'Master-Controls',
|
|
pflichtangabe: 'Pflichtangaben',
|
|
vendor: 'Vendor-Findings',
|
|
redundanz: 'Redundanzen',
|
|
}
|
|
|
|
const SEVERITY_COLOR: Record<string, string> = {
|
|
CRITICAL: 'bg-red-600 text-white',
|
|
HIGH: 'bg-red-100 text-red-800',
|
|
MEDIUM: 'bg-amber-100 text-amber-800',
|
|
LOW: 'bg-blue-100 text-blue-800',
|
|
INFO: 'bg-gray-100 text-gray-600',
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
failed: 'Fail',
|
|
passed: 'Pass',
|
|
skipped: 'Skip',
|
|
na: 'N/A',
|
|
info: 'Info',
|
|
}
|
|
|
|
const SEVERITY_OPTS = ['all', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
|
|
const STATUS_OPTS = ['all', 'failed', 'passed', 'skipped', 'na', 'info']
|
|
|
|
export default function FindingsTab({ checkId }: { checkId: string }) {
|
|
const [data, setData] = useState<Resp | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [source, setSource] = useState('all')
|
|
const [severity, setSeverity] = useState('all')
|
|
const [docType, setDocType] = useState('all')
|
|
const [status, setStatus] = useState('failed')
|
|
const [q, setQ] = useState('')
|
|
const [expanded, setExpanded] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
const qs = new URLSearchParams({
|
|
source, severity, doc_type: docType, status, q, limit: '1500',
|
|
}).toString()
|
|
fetch(`/api/sdk/v1/agent/findings/${checkId}?${qs}`)
|
|
.then(r => r.json())
|
|
.then(d => { if (!cancelled) setData(d) })
|
|
.catch(e => { if (!cancelled) setError(String(e)) })
|
|
.finally(() => { if (!cancelled) setLoading(false) })
|
|
return () => { cancelled = true }
|
|
}, [checkId, source, severity, docType, status, q])
|
|
|
|
const docTypes = useMemo(
|
|
() => Object.keys(data?.summary?.by_doc_type ?? {}).filter(d => d !== '-').sort(),
|
|
[data],
|
|
)
|
|
|
|
const csvExport = () => {
|
|
const rows = data?.findings ?? []
|
|
const head = ['Quelle', 'Doc', 'Severity', 'Status', 'Regulation', 'Label', 'Vendor', 'Hint']
|
|
const lines = [head.join(',')]
|
|
for (const r of rows) {
|
|
const cells = [
|
|
r.source_type, r.doc_type, r.severity, r.status,
|
|
r.regulation, r.label, r.vendor_name, r.hint,
|
|
].map(c => `"${String(c ?? '').replace(/"/g, '""').replace(/\n/g, ' ')}"`)
|
|
lines.push(cells.join(','))
|
|
}
|
|
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `findings-${checkId}.csv`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
if (loading && !data) return <div className="p-6 text-sm text-gray-500">Lade Voll-Audit…</div>
|
|
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
|
|
if (!data?.found) {
|
|
return (
|
|
<div className="p-6 text-sm text-gray-500">
|
|
Keine unified findings für diesen Run gespeichert (alter Run vor P5?).
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const sum = data.summary
|
|
const findings = data.findings
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
|
{Object.entries(SOURCE_LABEL).filter(([k]) => k !== 'all').map(([k, label]) => {
|
|
const count = sum.by_source?.[k] ?? 0
|
|
return (
|
|
<button key={k}
|
|
onClick={() => setSource(source === k ? 'all' : k)}
|
|
className={`text-left rounded-lg border px-3 py-2 transition ${
|
|
source === k
|
|
? 'border-blue-500 bg-blue-50 text-blue-900'
|
|
: 'border-gray-200 hover:border-gray-300 bg-white'
|
|
}`}>
|
|
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
|
|
<div className="text-lg font-semibold">{count}</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Filter row */}
|
|
<div className="flex flex-wrap gap-2 items-center text-xs">
|
|
<select value={severity} onChange={e => setSeverity(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1">
|
|
{SEVERITY_OPTS.map(s => (
|
|
<option key={s} value={s}>
|
|
{s === 'all' ? 'Alle Severities' : s}
|
|
{s !== 'all' && sum.by_severity?.[s] != null ? ` (${sum.by_severity[s]})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select value={status} onChange={e => setStatus(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1">
|
|
{STATUS_OPTS.map(s => (
|
|
<option key={s} value={s}>
|
|
{s === 'all' ? 'Alle Status' : STATUS_LABEL[s] ?? s}
|
|
{s !== 'all' && sum.by_status?.[s] != null ? ` (${sum.by_status[s]})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select value={docType} onChange={e => setDocType(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1">
|
|
<option value="all">Alle Doc-Types</option>
|
|
{docTypes.map(d => (
|
|
<option key={d} value={d}>{d} ({sum.by_doc_type?.[d] ?? 0})</option>
|
|
))}
|
|
</select>
|
|
<input value={q} onChange={e => setQ(e.target.value)}
|
|
placeholder="Suche Label / Anbieter…"
|
|
className="border border-gray-200 rounded px-2 py-1 min-w-[180px]" />
|
|
<button onClick={csvExport}
|
|
className="ml-auto border border-gray-200 hover:border-gray-300 rounded px-2 py-1">
|
|
CSV exportieren
|
|
</button>
|
|
<span className="text-gray-500">{data.count} Treffer</span>
|
|
</div>
|
|
|
|
{/* Findings table */}
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-gray-50 text-gray-600">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left">Quelle</th>
|
|
<th className="px-3 py-2 text-left">Doc</th>
|
|
<th className="px-3 py-2 text-left">Sev</th>
|
|
<th className="px-3 py-2 text-left">Status</th>
|
|
<th className="px-3 py-2 text-left">Finding</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{findings.map(f => (
|
|
<React.Fragment key={f.id}>
|
|
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
|
onClick={() => setExpanded(expanded === f.id ? null : f.id)}>
|
|
<td className="px-3 py-2 text-gray-500 capitalize">{f.source_type}</td>
|
|
<td className="px-3 py-2 text-gray-700">{f.doc_type === '-' ? '—' : f.doc_type}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
|
|
SEVERITY_COLOR[f.severity] || 'bg-gray-100'
|
|
}`}>{f.severity}</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-600">{STATUS_LABEL[f.status] ?? f.status}</td>
|
|
<td className="px-3 py-2 text-gray-900">
|
|
{f.label}
|
|
{f.vendor_name && (
|
|
<span className="ml-2 text-[10px] text-gray-400">
|
|
· {f.vendor_name}
|
|
</span>
|
|
)}
|
|
{(() => {
|
|
const rl = String(f.payload?.risk_label ?? '')
|
|
if (!rl) return null
|
|
const cls = rl === 'kritisch' ? 'bg-red-600 text-white' :
|
|
rl === 'hoch' ? 'bg-red-100 text-red-800' :
|
|
rl === 'mittel' ? 'bg-amber-100 text-amber-800' :
|
|
rl === 'gering' ? 'bg-green-50 text-green-700' :
|
|
'bg-gray-100 text-gray-500'
|
|
return <span className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${cls}`}>Risk: {rl}</span>
|
|
})()}
|
|
</td>
|
|
</tr>
|
|
{expanded === f.id && (
|
|
<tr className="bg-gray-50/50">
|
|
<td colSpan={5} className="px-3 py-3 text-xs space-y-2">
|
|
{f.hint && (
|
|
<div className="text-gray-700">{f.hint}</div>
|
|
)}
|
|
{f.action_recipe?.fix_text && (
|
|
<div className="bg-amber-50 border-l-2 border-amber-300 pl-3 py-2">
|
|
<div className="font-medium text-amber-800 mb-1">Empfehlung</div>
|
|
<div className="whitespace-pre-line text-amber-900">
|
|
{f.action_recipe.fix_text}
|
|
</div>
|
|
{f.action_recipe.where && (
|
|
<div className="text-[10px] text-amber-700 mt-1">
|
|
Einfuegen in: {f.action_recipe.where}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{f.anchor_excerpt && (
|
|
<div className="bg-blue-50 border-l-2 border-blue-300 pl-3 py-2">
|
|
<div className="font-medium text-blue-800 mb-1">
|
|
Fundstelle im Dokument (Konfidenz {Math.round((f.anchor_conf || 0) * 100)}%)
|
|
</div>
|
|
<div className="italic text-blue-900">"{f.anchor_excerpt}"</div>
|
|
</div>
|
|
)}
|
|
<div className="text-[10px] text-gray-400">
|
|
Source: {f.source_type} · Regulation: {f.regulation || '—'}
|
|
{f.category && ` · Kategorie: ${f.category}`}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
{findings.length === 0 && (
|
|
<tr><td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
|
Keine Findings fuer die aktuellen Filter.
|
|
</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|