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>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useState, useMemo } from 'react'
|
|
import { use as useUnwrap } from 'react'
|
|
import FindingsTab from './FindingsTab'
|
|
|
|
type MCRow = {
|
|
id: number
|
|
doc_type: string
|
|
mc_id: string
|
|
label: string
|
|
passed: number
|
|
skipped: number
|
|
severity: string
|
|
regulation: string
|
|
matched_text: string
|
|
hint: string
|
|
}
|
|
|
|
type ScorecardRow = {
|
|
regulation: string
|
|
total: number
|
|
passed: number
|
|
failed: number
|
|
skipped: number
|
|
pct: number
|
|
severity: Record<string, number>
|
|
}
|
|
|
|
type AuditResponse = {
|
|
found: boolean
|
|
run?: {
|
|
check_id: string
|
|
ts: string
|
|
site_name: string
|
|
base_domain: string
|
|
doc_count: number
|
|
scorecard: { by_regulation: ScorecardRow[]; totals: any }
|
|
vvt_summary: { total?: number; internal?: number; external?: number }
|
|
}
|
|
mc_count?: number
|
|
results?: MCRow[]
|
|
}
|
|
|
|
// P8: MC-Audit ist eine Checkliste, KEINE Severity-Drohung. Statt
|
|
// rotem HIGH-Badge zeigen wir die Quellen-Prioritaet (Gesetz vs.
|
|
// Behoerden-Leitlinie vs. Best-Practice) und einen 3-Tier-Status
|
|
// (erfuellt / nicht erfuellt / selbst pruefen).
|
|
|
|
const PRIORITY_BADGE: Record<string, string> = {
|
|
Gesetz: 'bg-slate-800 text-white',
|
|
'Behoerden-Leitlinie': 'bg-blue-100 text-blue-800',
|
|
'Best-Practice': 'bg-gray-100 text-gray-600',
|
|
'—': 'bg-gray-50 text-gray-400',
|
|
}
|
|
|
|
function regulationToPriority(reg: string): keyof typeof PRIORITY_BADGE {
|
|
const r = (reg || '').toLowerCase()
|
|
if (/dsgvo|gdpr|eprivacy|tdddg|tkg|bdsg|ttdsg/.test(r)) return 'Gesetz'
|
|
if (/edpb|dsk|cnil|lfdi|eugh|orientierungshilfe|leitlinie|guideline/.test(r))
|
|
return 'Behoerden-Leitlinie'
|
|
if (/iso|nist|bsi|cobit|sox/.test(r)) return 'Best-Practice'
|
|
return '—'
|
|
}
|
|
|
|
const _CONDITIONAL_RE = /\b(falls|sofern|wenn|soweit|ggf\.|gegebenenfalls)\b/i
|
|
|
|
function rowReviewStatus(r: MCRow): 'pass' | 'fail' | 'review' | 'na' {
|
|
if (r.passed) return 'pass'
|
|
if (r.skipped) return 'na'
|
|
// failed: harter Fail nur bei matched_text-Beleg ODER nicht-konditionalem Label
|
|
if (!r.matched_text && _CONDITIONAL_RE.test(r.label || '')) return 'review'
|
|
return 'fail'
|
|
}
|
|
|
|
const STATUS_FILTERS = [
|
|
{ value: 'all', label: 'Alle' },
|
|
{ value: 'fail', label: 'Nicht erfuellt' },
|
|
{ value: 'review', label: 'Selbst pruefen' },
|
|
{ value: 'pass', label: 'Erfuellt' },
|
|
{ value: 'na', label: 'Nicht anwendbar' },
|
|
] as const
|
|
|
|
export default function AuditPage(
|
|
{ params }: { params: Promise<{ checkId: string }> },
|
|
) {
|
|
const { checkId } = useUnwrap(params)
|
|
const [data, setData] = useState<AuditResponse | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [filterStatus, setFilterStatus] = useState<typeof STATUS_FILTERS[number]['value']>('fail')
|
|
const [filterReg, setFilterReg] = useState<string>('')
|
|
const [filterDoc, setFilterDoc] = useState<string>('')
|
|
const [expanded, setExpanded] = useState<number | null>(null)
|
|
const [tab, setTab] = useState<'mc' | 'all'>('all')
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
fetch(`/api/sdk/v1/agent/audit/${checkId}`)
|
|
.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])
|
|
|
|
const allRows = data?.results ?? []
|
|
const docTypes = useMemo(
|
|
() => Array.from(new Set(allRows.map(r => r.doc_type))).sort(),
|
|
[allRows],
|
|
)
|
|
const regulations = useMemo(
|
|
() => Array.from(new Set(allRows.map(r => r.regulation).filter(Boolean))).sort(),
|
|
[allRows],
|
|
)
|
|
|
|
const filtered = allRows.filter(r => {
|
|
if (filterStatus !== 'all' && rowReviewStatus(r) !== filterStatus) return false
|
|
if (filterReg && r.regulation !== filterReg) return false
|
|
if (filterDoc && r.doc_type !== filterDoc) return false
|
|
return true
|
|
})
|
|
|
|
if (loading) {
|
|
return <div className="p-6 text-sm text-gray-500">Lade Audit…</div>
|
|
}
|
|
if (error || !data?.found) {
|
|
return (
|
|
<div className="p-6 text-sm text-red-600">
|
|
Audit nicht gefunden{error ? `: ${error}` : ''}.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const run = data.run!
|
|
const scorecard = run.scorecard?.by_regulation ?? []
|
|
const totals = run.scorecard?.totals ?? { total: 0, passed: 0, failed: 0, pct: 0 }
|
|
|
|
return (
|
|
<div className="space-y-6 p-6 max-w-6xl">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-gray-900">
|
|
MC-Audit: {run.site_name}
|
|
</h1>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
check_id <code className="bg-gray-100 px-1 rounded">{checkId}</code> ·{' '}
|
|
{new Date(run.ts).toLocaleString('de-DE')} · {run.doc_count} Dokumente ·{' '}
|
|
{data.mc_count} MC-Eintraege
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tab switcher */}
|
|
<div className="flex gap-2 border-b border-gray-200">
|
|
{([
|
|
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
|
|
{ key: 'mc', label: 'Nur MC-Scorecard' },
|
|
] as const).map(t => (
|
|
<button key={t.key}
|
|
onClick={() => setTab(t.key)}
|
|
className={`px-4 py-2 text-sm border-b-2 -mb-px transition ${
|
|
tab === t.key
|
|
? 'border-blue-600 text-blue-700 font-medium'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
}`}>{t.label}</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'all' && <FindingsTab checkId={checkId} />}
|
|
|
|
{tab === 'mc' && <>
|
|
{/* Scorecard */}
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<div className="px-4 py-3 bg-blue-50 border-b border-blue-100">
|
|
<h2 className="text-sm font-medium text-blue-900">
|
|
Compliance-Scorecard nach Regulation
|
|
<span className="ml-2 text-blue-700 font-semibold text-base">
|
|
{totals.pct}%
|
|
</span>
|
|
<span className="ml-2 text-xs text-blue-600">
|
|
({totals.passed} bestanden, {totals.failed} Fail,{' '}
|
|
{totals.skipped} skipped — {totals.total} gesamt)
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-gray-50 text-gray-600">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left">Regulation</th>
|
|
<th className="px-3 py-2 text-center">Passed</th>
|
|
<th className="px-3 py-2 text-center">Failed</th>
|
|
<th className="px-3 py-2 text-center">HIGH</th>
|
|
<th className="px-3 py-2 text-center">MEDIUM</th>
|
|
<th className="px-3 py-2 text-right">Score</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{scorecard.map(row => (
|
|
<tr key={row.regulation} className="border-t hover:bg-blue-50/30 cursor-pointer"
|
|
onClick={() => setFilterReg(row.regulation === filterReg ? '' : row.regulation)}>
|
|
<td className="px-3 py-2 font-medium">{row.regulation}</td>
|
|
<td className="px-3 py-2 text-center text-green-700">{row.passed}</td>
|
|
<td className="px-3 py-2 text-center text-red-700">{row.failed}</td>
|
|
<td className="px-3 py-2 text-center text-red-700">
|
|
{(row.severity.HIGH || 0) + (row.severity.CRITICAL || 0)}
|
|
</td>
|
|
<td className="px-3 py-2 text-center text-amber-700">
|
|
{row.severity.MEDIUM || 0}
|
|
</td>
|
|
<td className={`px-3 py-2 text-right font-semibold ${
|
|
row.pct >= 80 ? 'text-green-700' :
|
|
row.pct >= 50 ? 'text-amber-700' : 'text-red-700'
|
|
}`}>{row.pct}%</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-3 items-center text-xs">
|
|
<div className="flex gap-1">
|
|
{STATUS_FILTERS.map(f => (
|
|
<button key={f.value}
|
|
onClick={() => setFilterStatus(f.value)}
|
|
className={`px-2.5 py-1 rounded-full border ${
|
|
filterStatus === f.value
|
|
? 'bg-blue-600 text-white border-blue-600'
|
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
|
}`}>{f.label}</button>
|
|
))}
|
|
</div>
|
|
<select value={filterDoc} onChange={e => setFilterDoc(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1">
|
|
<option value="">Alle Doc-Types</option>
|
|
{docTypes.map(d => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
<select value={filterReg} onChange={e => setFilterReg(e.target.value)}
|
|
className="border border-gray-200 rounded px-2 py-1">
|
|
<option value="">Alle Regulations</option>
|
|
{regulations.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
<span className="text-gray-500">
|
|
{filtered.length} von {allRows.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<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">Status</th>
|
|
<th className="px-3 py-2 text-left">Doc</th>
|
|
<th className="px-3 py-2 text-left">Regulation</th>
|
|
<th className="px-3 py-2 text-left">MC</th>
|
|
<th className="px-3 py-2 text-left">Prioritaet</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map(row => (
|
|
<React.Fragment key={row.id}>
|
|
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
|
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
|
|
<td className="px-3 py-2">
|
|
{(() => {
|
|
const st = rowReviewStatus(row)
|
|
if (st === 'pass') return <span className="text-green-600" title="Erfuellt">✓</span>
|
|
if (st === 'na') return <span className="text-gray-400" title="Nicht anwendbar">—</span>
|
|
if (st === 'review') return <span className="text-amber-600" title="Selbst pruefen">?</span>
|
|
return <span className="text-red-600" title="Nicht erfuellt">✗</span>
|
|
})()}
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-700">{row.doc_type}</td>
|
|
<td className="px-3 py-2 text-gray-500">{row.regulation || '—'}</td>
|
|
<td className="px-3 py-2 text-gray-900">{row.label}</td>
|
|
<td className="px-3 py-2">
|
|
{(() => {
|
|
const prio = regulationToPriority(row.regulation)
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${PRIORITY_BADGE[prio]}`}>
|
|
{prio}
|
|
</span>
|
|
)
|
|
})()}
|
|
</td>
|
|
</tr>
|
|
{expanded === row.id && (
|
|
<tr className="bg-gray-50/50">
|
|
<td colSpan={5} className="px-3 py-3 text-xs">
|
|
<div className="text-gray-500 mb-1">
|
|
MC-ID: <code>{row.mc_id}</code>
|
|
</div>
|
|
{row.matched_text && (
|
|
<div className="mb-2">
|
|
<span className="text-green-700 font-medium">Treffer: </span>
|
|
<span className="font-mono text-gray-700">
|
|
"{row.matched_text}"
|
|
</span>
|
|
</div>
|
|
)}
|
|
{row.hint && (
|
|
<div className="text-amber-700 bg-amber-50 border-l-2 border-amber-200 pl-2 py-1">
|
|
{row.hint}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
|
Keine MCs entsprechen den aktuellen Filtern.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>}
|
|
</div>
|
|
)
|
|
}
|