From 575644c9c5334925970a6375943539d0ec7899db Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 19 May 2026 00:30:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(audit):=20P8=20=E2=80=94=20MC-Severity=20r?= =?UTF-8?q?aus,=20Email=20nur=20harte=20Findings,=20MC-Audit=20als=20Check?= =?UTF-8?q?liste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../sdk/agent/audit/[checkId]/FindingsTab.tsx | 17 +++-- .../app/sdk/agent/audit/[checkId]/page.tsx | 75 +++++++++++++------ .../compliance/services/mc_scorecard.py | 29 ++++++- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/admin-compliance/app/sdk/agent/audit/[checkId]/FindingsTab.tsx b/admin-compliance/app/sdk/agent/audit/[checkId]/FindingsTab.tsx index 836d88c1..843b787c 100644 --- a/admin-compliance/app/sdk/agent/audit/[checkId]/FindingsTab.tsx +++ b/admin-compliance/app/sdk/agent/audit/[checkId]/FindingsTab.tsx @@ -214,15 +214,16 @@ export default function FindingsTab({ checkId }: { checkId: string }) { · {f.vendor_name} )} - {f.payload?.risk_label && ( - { + 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' - }`}>Risk: {String(f.payload.risk_label)} - )} + return Risk: {rl} + })()} {expanded === f.id && ( diff --git a/admin-compliance/app/sdk/agent/audit/[checkId]/page.tsx b/admin-compliance/app/sdk/agent/audit/[checkId]/page.tsx index ba844d5f..1891357a 100644 --- a/admin-compliance/app/sdk/agent/audit/[checkId]/page.tsx +++ b/admin-compliance/app/sdk/agent/audit/[checkId]/page.tsx @@ -42,19 +42,43 @@ type AuditResponse = { results?: MCRow[] } -const SEVERITY_COLOR: Record = { - 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', +// 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 = { + 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: 'failed', label: 'Nur Fail' }, - { value: 'passed', label: 'Nur Pass' }, - { value: 'skipped', label: 'Nur Skipped' }, + { 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( @@ -64,7 +88,7 @@ export default function AuditPage( const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [filterStatus, setFilterStatus] = useState('failed') + const [filterStatus, setFilterStatus] = useState('fail') const [filterReg, setFilterReg] = useState('') const [filterDoc, setFilterDoc] = useState('') const [expanded, setExpanded] = useState(null) @@ -92,9 +116,7 @@ export default function AuditPage( ) const filtered = allRows.filter(r => { - if (filterStatus === 'failed' && (r.passed || r.skipped)) return false - if (filterStatus === 'passed' && !r.passed) return false - if (filterStatus === 'skipped' && !r.skipped) return false + 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 @@ -233,7 +255,7 @@ export default function AuditPage( Doc Regulation MC - Severity + Prioritaet @@ -242,21 +264,26 @@ export default function AuditPage( setExpanded(expanded === row.id ? null : row.id)}> - {row.passed ? ( - - ) : row.skipped ? ( - - ) : ( - - )} + {(() => { + const st = rowReviewStatus(row) + if (st === 'pass') return + if (st === 'na') return + if (st === 'review') return ? + return + })()} {row.doc_type} {row.regulation || '—'} {row.label} - {row.severity || '—'} + {(() => { + const prio = regulationToPriority(row.regulation) + return ( + + {prio} + + ) + })()} {expanded === row.id && ( diff --git a/backend-compliance/compliance/services/mc_scorecard.py b/backend-compliance/compliance/services/mc_scorecard.py index 20a2d456..ecb09e34 100644 --- a/backend-compliance/compliance/services/mc_scorecard.py +++ b/backend-compliance/compliance/services/mc_scorecard.py @@ -121,11 +121,37 @@ def _dedup_key(label: str) -> str: return label +_CONDITIONAL_MARKERS = ("falls ", "sofern ", "wenn ", "soweit ", + "bei bedarf", "ggf.", "gegebenenfalls") + + +def _is_hard_finding(r: dict) -> bool: + """Echtes Finding = wir haben einen positiven Treffer im Text der den + Verstoss belegt. Stille im Text reicht NICHT — das wandert ins MC-Audit + als "selbst pruefen", nicht ins Email als HIGH-Drohung. + + Heuristik: + - matched_text nicht leer = textuelle Evidenz vorhanden → hart + - konditionales Label ("falls / sofern / wenn") UND matched_text leer + → weich (Pre-Condition nicht belegt) → raus aus Top-Fails + - sonst: hart (klassische Pflichtangaben-Lücke wie "DSB fehlt") + """ + mt = (r.get("matched_text") or "").strip() + if mt: + return True + label_low = (r.get("label") or "").lower() + if any(m in label_low for m in _CONDITIONAL_MARKERS): + return False + return True + + def top_fails(check_results: list[dict], n: int = 10) -> list[dict]: """Return top-N failing MCs sorted by severity then label. Skipped + passed MCs are excluded. INFO severity is excluded by - default since those are guidance, not findings. + default since those are guidance, not findings. Konditionale MCs + ohne Negativ-Beleg (P8) werden ebenfalls ausgesteuert — sie + erscheinen nur noch im MC-Audit als "selbst pruefen". Near-duplicates (multiple MCs that all complain about "einfache Sprache" / "Einwilligungsaufforderung" / ...) are collapsed to ONE @@ -136,6 +162,7 @@ def top_fails(check_results: list[dict], n: int = 10) -> list[dict]: r for r in (check_results or []) if not r.get("passed") and not r.get("skipped") and (r.get("severity") or "").upper() != "INFO" + and _is_hard_finding(r) ] fails.sort(key=lambda r: ( _SEV_RANK.get((r.get("severity") or "MEDIUM").upper(), 5),