feat(audit): P8 — MC-Severity raus, Email nur harte Findings, MC-Audit als Checkliste
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
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>
This commit is contained in:
@@ -214,15 +214,16 @@ export default function FindingsTab({ checkId }: { checkId: string }) {
|
|||||||
· {f.vendor_name}
|
· {f.vendor_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{f.payload?.risk_label && (
|
{(() => {
|
||||||
<span className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
const rl = String(f.payload?.risk_label ?? '')
|
||||||
f.payload.risk_label === 'kritisch' ? 'bg-red-600 text-white' :
|
if (!rl) return null
|
||||||
f.payload.risk_label === 'hoch' ? 'bg-red-100 text-red-800' :
|
const cls = rl === 'kritisch' ? 'bg-red-600 text-white' :
|
||||||
f.payload.risk_label === 'mittel' ? 'bg-amber-100 text-amber-800' :
|
rl === 'hoch' ? 'bg-red-100 text-red-800' :
|
||||||
f.payload.risk_label === 'gering' ? 'bg-green-50 text-green-700' :
|
rl === 'mittel' ? 'bg-amber-100 text-amber-800' :
|
||||||
|
rl === 'gering' ? 'bg-green-50 text-green-700' :
|
||||||
'bg-gray-100 text-gray-500'
|
'bg-gray-100 text-gray-500'
|
||||||
}`}>Risk: {String(f.payload.risk_label)}</span>
|
return <span className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${cls}`}>Risk: {rl}</span>
|
||||||
)}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded === f.id && (
|
{expanded === f.id && (
|
||||||
|
|||||||
@@ -42,19 +42,43 @@ type AuditResponse = {
|
|||||||
results?: MCRow[]
|
results?: MCRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVERITY_COLOR: Record<string, string> = {
|
// P8: MC-Audit ist eine Checkliste, KEINE Severity-Drohung. Statt
|
||||||
CRITICAL: 'bg-red-600 text-white',
|
// rotem HIGH-Badge zeigen wir die Quellen-Prioritaet (Gesetz vs.
|
||||||
HIGH: 'bg-red-100 text-red-800',
|
// Behoerden-Leitlinie vs. Best-Practice) und einen 3-Tier-Status
|
||||||
MEDIUM: 'bg-amber-100 text-amber-800',
|
// (erfuellt / nicht erfuellt / selbst pruefen).
|
||||||
LOW: 'bg-blue-100 text-blue-800',
|
|
||||||
INFO: 'bg-gray-100 text-gray-600',
|
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 = [
|
const STATUS_FILTERS = [
|
||||||
{ value: 'all', label: 'Alle' },
|
{ value: 'all', label: 'Alle' },
|
||||||
{ value: 'failed', label: 'Nur Fail' },
|
{ value: 'fail', label: 'Nicht erfuellt' },
|
||||||
{ value: 'passed', label: 'Nur Pass' },
|
{ value: 'review', label: 'Selbst pruefen' },
|
||||||
{ value: 'skipped', label: 'Nur Skipped' },
|
{ value: 'pass', label: 'Erfuellt' },
|
||||||
|
{ value: 'na', label: 'Nicht anwendbar' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function AuditPage(
|
export default function AuditPage(
|
||||||
@@ -64,7 +88,7 @@ export default function AuditPage(
|
|||||||
const [data, setData] = useState<AuditResponse | null>(null)
|
const [data, setData] = useState<AuditResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [filterStatus, setFilterStatus] = useState<typeof STATUS_FILTERS[number]['value']>('failed')
|
const [filterStatus, setFilterStatus] = useState<typeof STATUS_FILTERS[number]['value']>('fail')
|
||||||
const [filterReg, setFilterReg] = useState<string>('')
|
const [filterReg, setFilterReg] = useState<string>('')
|
||||||
const [filterDoc, setFilterDoc] = useState<string>('')
|
const [filterDoc, setFilterDoc] = useState<string>('')
|
||||||
const [expanded, setExpanded] = useState<number | null>(null)
|
const [expanded, setExpanded] = useState<number | null>(null)
|
||||||
@@ -92,9 +116,7 @@ export default function AuditPage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const filtered = allRows.filter(r => {
|
const filtered = allRows.filter(r => {
|
||||||
if (filterStatus === 'failed' && (r.passed || r.skipped)) return false
|
if (filterStatus !== 'all' && rowReviewStatus(r) !== filterStatus) return false
|
||||||
if (filterStatus === 'passed' && !r.passed) return false
|
|
||||||
if (filterStatus === 'skipped' && !r.skipped) return false
|
|
||||||
if (filterReg && r.regulation !== filterReg) return false
|
if (filterReg && r.regulation !== filterReg) return false
|
||||||
if (filterDoc && r.doc_type !== filterDoc) return false
|
if (filterDoc && r.doc_type !== filterDoc) return false
|
||||||
return true
|
return true
|
||||||
@@ -233,7 +255,7 @@ export default function AuditPage(
|
|||||||
<th className="px-3 py-2 text-left">Doc</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">Regulation</th>
|
||||||
<th className="px-3 py-2 text-left">MC</th>
|
<th className="px-3 py-2 text-left">MC</th>
|
||||||
<th className="px-3 py-2 text-left">Severity</th>
|
<th className="px-3 py-2 text-left">Prioritaet</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -242,21 +264,26 @@ export default function AuditPage(
|
|||||||
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
<tr className="border-t cursor-pointer hover:bg-gray-50"
|
||||||
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
|
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.passed ? (
|
{(() => {
|
||||||
<span className="text-green-600">✓</span>
|
const st = rowReviewStatus(row)
|
||||||
) : row.skipped ? (
|
if (st === 'pass') return <span className="text-green-600" title="Erfuellt">✓</span>
|
||||||
<span className="text-gray-400">—</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>
|
||||||
<span className="text-red-600">✗</span>
|
return <span className="text-red-600" title="Nicht erfuellt">✗</span>
|
||||||
)}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-700">{row.doc_type}</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-500">{row.regulation || '—'}</td>
|
||||||
<td className="px-3 py-2 text-gray-900">{row.label}</td>
|
<td className="px-3 py-2 text-gray-900">{row.label}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
|
{(() => {
|
||||||
SEVERITY_COLOR[row.severity] || 'bg-gray-100'
|
const prio = regulationToPriority(row.regulation)
|
||||||
}`}>{row.severity || '—'}</span>
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${PRIORITY_BADGE[prio]}`}>
|
||||||
|
{prio}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded === row.id && (
|
{expanded === row.id && (
|
||||||
|
|||||||
@@ -121,11 +121,37 @@ def _dedup_key(label: str) -> str:
|
|||||||
return label
|
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]:
|
def top_fails(check_results: list[dict], n: int = 10) -> list[dict]:
|
||||||
"""Return top-N failing MCs sorted by severity then label.
|
"""Return top-N failing MCs sorted by severity then label.
|
||||||
|
|
||||||
Skipped + passed MCs are excluded. INFO severity is excluded by
|
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
|
Near-duplicates (multiple MCs that all complain about "einfache
|
||||||
Sprache" / "Einwilligungsaufforderung" / ...) are collapsed to ONE
|
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 [])
|
r for r in (check_results or [])
|
||||||
if not r.get("passed") and not r.get("skipped")
|
if not r.get("passed") and not r.get("skipped")
|
||||||
and (r.get("severity") or "").upper() != "INFO"
|
and (r.get("severity") or "").upper() != "INFO"
|
||||||
|
and _is_hard_finding(r)
|
||||||
]
|
]
|
||||||
fails.sort(key=lambda r: (
|
fails.sort(key=lambda r: (
|
||||||
_SEV_RANK.get((r.get("severity") or "MEDIUM").upper(), 5),
|
_SEV_RANK.get((r.get("severity") or "MEDIUM").upper(), 5),
|
||||||
|
|||||||
Reference in New Issue
Block a user