Files
breakpilot-compliance/backend-compliance/compliance/services/mc_scorecard.py
T
Benjamin Admin 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
feat(audit): P8 — MC-Severity raus, Email nur harte Findings, MC-Audit als Checkliste
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>
2026-05-19 00:30:04 +02:00

214 lines
7.2 KiB
Python

"""
Master-Control Scorecard — group + summarise MC results.
With max_controls=0 (#30 fix) every doc-check now evaluates 75-571 MCs
per document. Rendering all of them verbatim makes the email + frontend
unreadable. This module produces three structured artefacts:
1. `build_scorecard(check_results)` — per-regulation aggregate (PASS /
FAIL / SKIP counts + severity histogram + compliance %)
2. `top_fails(check_results, n=10)` — top-N failed MCs ranked by
severity then absence of evidence
3. `full_audit_records(check_results, check_id, tenant_id)` — flat
list ready for SQLite persistence + JSON export
The functions are pure — no DB / network — so they're cheap to call
from inside the route and unit-testable.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
# Severity order: CRITICAL > HIGH > MEDIUM > LOW > INFO
_SEV_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
def build_scorecard(check_results: list[dict]) -> dict:
"""Aggregate per-regulation pass/fail/skip + severity buckets.
Args:
check_results: list of dicts, each typically a CheckItem-like
record with keys: id, label, passed, severity, skipped,
regulation, doc_type.
Returns:
{
"by_regulation": [
{"regulation": "DSGVO", "total": 193, "passed": 167,
"failed": 24, "skipped": 2, "pct": 87,
"severity": {"HIGH": 22, "MEDIUM": 2}}
],
"totals": {"total": 1874, "passed": 1300, "failed": 540,
"skipped": 34, "pct": 70},
}
"""
buckets: dict[str, dict] = defaultdict(
lambda: {"total": 0, "passed": 0, "failed": 0, "skipped": 0,
"severity": defaultdict(int)},
)
for r in check_results or []:
reg = (r.get("regulation") or "").strip() or ""
b = buckets[reg]
b["total"] += 1
if r.get("skipped"):
b["skipped"] += 1
elif r.get("passed"):
b["passed"] += 1
else:
b["failed"] += 1
sev = (r.get("severity") or "MEDIUM").upper()
b["severity"][sev] += 1
rows = []
grand_total = grand_passed = grand_failed = grand_skipped = 0
for reg, b in buckets.items():
# Convert defaultdict for serialisability
sev_dict = dict(b["severity"])
active = b["total"] - b["skipped"]
pct = round(b["passed"] / active * 100) if active else 0
rows.append({
"regulation": reg,
"total": b["total"],
"passed": b["passed"],
"failed": b["failed"],
"skipped": b["skipped"],
"pct": pct,
"severity": sev_dict,
})
grand_total += b["total"]
grand_passed += b["passed"]
grand_failed += b["failed"]
grand_skipped += b["skipped"]
rows.sort(key=lambda r: (-r["failed"], r["regulation"]))
grand_active = grand_total - grand_skipped
grand_pct = round(grand_passed / grand_active * 100) if grand_active else 0
return {
"by_regulation": rows,
"totals": {
"total": grand_total, "passed": grand_passed,
"failed": grand_failed, "skipped": grand_skipped,
"pct": grand_pct,
},
}
_DEDUP_KEYWORDS = [
"einfache sprache", "verstaendliche sprache", "verständliche sprache",
"klare sprache", "einwilligungstexte", "einwilligungsaufforderung",
"einwilligungserklaerung", "einwilligungserklärung",
"mehrdeutige", "verstaendliche form", "verständliche form",
"fachbegriffe erklaeren", "fachbegriffe erklären",
]
def _dedup_key(label: str) -> str:
"""Cluster label to a stable dedup-key: if it contains one of the
well-known repetitive Sprache/Einwilligungs-Aufforderungs-Concepts,
collapse them all to that single concept. Otherwise return original."""
l = (label or "").lower()
for kw in _DEDUP_KEYWORDS:
if kw in l:
return f"_dup:{kw}"
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. 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
representative entry — sonst dominieren UI-Sprache-Hinweise die
Top-Liste und echte Lecks gehen unter.
"""
fails = [
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),
r.get("label", ""),
))
seen_keys: set[str] = set()
deduped: list[dict] = []
for r in fails:
k = _dedup_key(r.get("label", ""))
if k in seen_keys:
continue
seen_keys.add(k)
deduped.append(r)
if len(deduped) >= n:
break
return deduped
def full_audit_records(
check_results: list[dict],
check_id: str,
tenant_id: str = "",
doc_type: str = "",
) -> list[dict]:
"""Flatten check results into rows ready for SQLite persistence.
Returns one record per MC. Keeps the original fields plus
check_id + doc_type + tenant_id + ts.
"""
ts = datetime.now(timezone.utc).isoformat()
out: list[dict] = []
for r in check_results or []:
out.append({
"check_id": check_id,
"tenant_id": tenant_id,
"doc_type": doc_type,
"ts": ts,
"mc_id": r.get("id", ""),
"label": (r.get("label") or "")[:300],
"passed": bool(r.get("passed")),
"skipped": bool(r.get("skipped")),
"severity": (r.get("severity") or "").upper(),
"regulation": r.get("regulation") or "",
"matched_text": (r.get("matched_text") or "")[:500],
"hint": (r.get("hint") or "")[:500],
"level": int(r.get("level") or 1),
})
return out