From 08671adfdfc74a6ab1889b0a05181498967f2aa2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 16:20:19 +0200 Subject: [PATCH] feat(audit): P82 GF-1-Pager + P87 Konfidenz-Score pro Finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P82 — gf_one_pager.py: kompakte 5-Bullet-Kurzfassung ganz oben in der Mail. Score (gross + Farbe), Delta-zu-Vorlauf, Top-Findings nach HIGH/MEDIUM sortiert mit zustaendiger Rolle (DSB / Marketing / IT / Legal / Web-Team) und Klassifizierungsbits aus dem Wizard. Sachlicher Ton — keine 4%-Drohung, '4-8 Wochen' als realistischer Zeitrahmen. Eingehaengt vor Critical-Findings-Block in Mail-Composition und Replay-Pipeline. P87 — finding_confidence.py: 13 Regex-Regeln liefern (confidence_pct, reason) pro Finding-Label. Direkt im DOM beobachtbar = 95-98%, Library-Mismatch = 82%, Textmuster-Match auf Pflichtangaben = 75-88%. Im 1-Pager als kleines '(NN% Konfidenz)'-Tag mit Reason-Tooltip hinter jedem Finding gerendert. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/agent_compliance_check_routes.py | 20 +- .../compliance/services/check_replay.py | 15 + .../compliance/services/finding_confidence.py | 86 ++++++ .../compliance/services/gf_one_pager.py | 291 ++++++++++++++++++ 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 backend-compliance/compliance/services/finding_confidence.py create mode 100644 backend-compliance/compliance/services/gf_one_pager.py diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index f9b2ded6..4fbd9225 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -1049,6 +1049,7 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): # P102: Cookie-Klassifikations-Pruefung (deklariert vs Library) library_mismatch_html = "" + mismatches: list[dict] = [] try: from compliance.services.cookie_library_mismatch import ( detect_mismatches, build_mismatch_block_html, @@ -1080,8 +1081,25 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): except Exception as e: logger.warning("P102 mismatch detection failed: %s", e) + # P82: GF-1-Pager ganz oben in der Mail — 5-Bullet-Zusammenfassung + # damit die GF nicht 124k Char lesen muss. + gf_one_pager_html = "" + try: + from compliance.services.gf_one_pager import build_gf_one_pager_html + gf_one_pager_html = build_gf_one_pager_html( + site_name=site_name_for_exec, + scorecard=scorecard, + previous_scorecard=prev_scorecard, + banner_result=banner_result, + library_mismatch_findings=mismatches, + scan_context=req.scan_context, + ) + except Exception as e: + logger.warning("P82 GF-1-pager skipped: %s", e) + full_html = ( - critical_html + scope_disclaimer_html + exec_summary_html + gf_one_pager_html + + critical_html + scope_disclaimer_html + exec_summary_html + cookie_arch_html + summary_html + scanned_html + profile_html + scorecard_html + redundancy_html + providers_html + banner_deep_html + library_mismatch_html diff --git a/backend-compliance/compliance/services/check_replay.py b/backend-compliance/compliance/services/check_replay.py index 08e34515..bdf18a21 100644 --- a/backend-compliance/compliance/services/check_replay.py +++ b/backend-compliance/compliance/services/check_replay.py @@ -85,6 +85,21 @@ def replay_from_snapshot( section_sizes: dict[str, int] = {} parts: list[str] = [] + # P82: GF-1-Pager zuerst (5-Bullet-Summary) + try: + from compliance.services.gf_one_pager import build_gf_one_pager_html + gf_html = build_gf_one_pager_html( + site_name=site_label or "", + scorecard=None, # Snapshot enthaelt keine MC-Scorecard + banner_result=banner_result, + library_mismatch_findings=None, # wird unten gefuellt + scan_context=snap.get("scan_context"), + ) + parts.append(gf_html) + section_sizes["gf_one_pager"] = len(gf_html) + except Exception as e: + logger.warning("Replay: GF-1-pager failed: %s", e) + try: from compliance.api.agent_doc_check_critical import build_critical_findings_html critical_html = build_critical_findings_html(banner_result, None, results) or "" diff --git a/backend-compliance/compliance/services/finding_confidence.py b/backend-compliance/compliance/services/finding_confidence.py new file mode 100644 index 00000000..eac8fe46 --- /dev/null +++ b/backend-compliance/compliance/services/finding_confidence.py @@ -0,0 +1,86 @@ +""" +P87 — Konfidenz-Score pro Finding. + +Nicht jedes HIGH-Finding ist gleich sicher. "Kein Reject-Button im Banner" +ist faktisch direkt beobachtbar (Confidence ~98%). "DSE enthaelt keinen +DSB-Kontakt" ist ein Textmuster-Match und kann False-Positive sein +(Confidence ~70%). "Cookie X als essential deklariert, Library sagt +marketing" haengt von Library-Qualitaet ab (Confidence ~80%). + +Liefert pro Finding-Label ein (confidence_pct, reason) Paar. Wird im +Mail-Render als kleine graue Klammer hinter dem Severity-Pill angezeigt: +"HOCH (95% Konfidenz: Direkt im DOM beobachtet)". + +Keine ML — nur regelbasiert. Eine zentrale Stelle damit alle Render- +Stellen einheitlich klassifizieren. +""" + +from __future__ import annotations + +import re + +# (regex, confidence_pct, reason) +# Reihenfolge wichtig: spezifischere Patterns zuerst. +_RULES: list[tuple[re.Pattern, int, str]] = [ + # 1) Direkt im DOM / im Cookie-Jar beobachtet — sehr hohe Sicherheit + (re.compile(r"reject[- ]?button.*(fehlt|nicht.*vorhanden)", re.I), 98, + "Direkt im Banner-DOM ueberprueft"), + (re.compile(r"(anpassen|einstellungen|customize).*button.*fehlt", re.I), 95, + "Initial-Banner-DOM ueberprueft"), + (re.compile(r"cookie.*vor.*einwilligung.*gesetzt", re.I), 96, + "Cookie-Jar vor Akzeptieren beobachtet"), + (re.compile(r"(tracking|marketing).*ohne.*einwilligung", re.I), 92, + "Network-Calls vor Akzeptieren beobachtet"), + + # 2) Library-Mismatches — abhaengig von Library-Qualitaet + (re.compile(r"deklariert als.*library.*sagt", re.I), 82, + "Vergleich mit ~2.300-Cookie-Library + Open-Cookie-DB"), + (re.compile(r"library.*marketing", re.I), 82, + "Cookie-Library-Klassifikation"), + + # 3) Pflichtangaben-Checks (Impressum/AGB/DSE) — Textmuster, MEDIUM-Sicherheit + (re.compile(r"impressum.*(fehlt|unvollstaendig)", re.I), 88, + "Pattern-Match auf Impressums-Pflichtfelder (§ 5 TMG)"), + (re.compile(r"dsb.*(fehlt|nicht.*genannt)", re.I), 75, + "Textmuster-Suche; DSB kann ueber Impressum referenziert sein"), + (re.compile(r"drittland.*(fehlt|nicht.*genannt|ohne.*hinweis)", re.I), 80, + "Pattern-Match auf typische Drittland-Klauseln"), + (re.compile(r"widerruf.*(fehlt|unvollstaendig)", re.I), 85, + "Pattern-Match auf Widerrufsbelehrungs-Pflichtfelder"), + + # 4) Anti-Auditing-Detection — heuristisch + (re.compile(r"anti[- ]?audit", re.I), 70, + "Skript-Domain-Heuristik; manuelle Pruefung empfohlen"), + + # 5) Generische Konsistenz-Findings (DSE vs. Banner vs. Cookie-Liste) + (re.compile(r"banner.*nennt.*\d+.*cmp.*\d+", re.I), 90, + "Quantitativer Vergleich zwischen Banner-Text und CMP-Payload"), + + # 6) Klassifikations- / Kontext-Findings (Wizard-getrieben) + (re.compile(r"(branchen|scope).*passt.*nicht", re.I), 88, + "Wizard-Klassifikation + MC-scope_doc_type"), +] + +_DEFAULT_CONFIDENCE = 78 +_DEFAULT_REASON = ( + "Standard-Regelpruefung; Bestaetigung mit DSB / interner Doku empfohlen" +) + + +def score_finding(label: str) -> tuple[int, str]: + """Returns (confidence_pct, reason) for a finding label.""" + if not label: + return _DEFAULT_CONFIDENCE, _DEFAULT_REASON + for pat, conf, reason in _RULES: + if pat.search(label): + return conf, reason + return _DEFAULT_CONFIDENCE, _DEFAULT_REASON + + +def confidence_pill_html(label: str) -> str: + """Returns an inline HTML snippet '(NN% Konfidenz: ...)' or empty.""" + conf, reason = score_finding(label) + return ( + f' ' + f'({conf}% Konfidenz)' + ) diff --git a/backend-compliance/compliance/services/gf_one_pager.py b/backend-compliance/compliance/services/gf_one_pager.py new file mode 100644 index 00000000..d10ebc41 --- /dev/null +++ b/backend-compliance/compliance/services/gf_one_pager.py @@ -0,0 +1,291 @@ +""" +P82 — GF-1-Pager (Geschaeftsfuehrer-Kurzfassung). + +Eine kompakte 5-7-Bullet-Zusammenfassung ganz oben in der Mail. GF liest +sonst die 124k-Char-Komplettpruefung nicht. Ton sachlich, keine Panik +(Memory: feedback_breakpilot_tonalitaet). + +Bildet ab: +- Compliance-Score + Vergleichswert (wenn Vorlauf vorhanden) +- Top-3 priorisierte Themen (HIGH oder kritisches MEDIUM) +- Aufwand-Schaetzung (4-8 Wochen) + Wer-macht-was (DSB / IT / Marketing) +- Realer Risiko-Hinweis (ohne 4%-Weltumsatz-Drohung) + +Wird VOR Critical-Findings und Exec-Summary gerendert. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +_AREA_LABEL = { + "banner": "Cookie-Banner", + "cookie": "Cookie-Richtlinie", + "dse": "Datenschutzerklaerung", + "impressum": "Impressum", + "agb": "AGB", + "library_mismatch": "Cookie-Klassifikation", + "vendor": "Vendor-Liste / VVT", + "consent": "Einwilligung", + "rights": "Betroffenenrechte", +} + + +def _normalize_finding(item: dict) -> dict: + sev = str(item.get("severity") or item.get("level") or "").upper() + if sev not in ("HIGH", "MEDIUM", "LOW"): + sev = "MEDIUM" + label = (item.get("label") or item.get("title") + or item.get("check") or item.get("name") or "").strip() + if not label: + return {} + area = (item.get("area") or item.get("doc_type") or item.get("category") or "").lower() + return { + "severity": sev, + "label": label[:200], + "area": _AREA_LABEL.get(area, area.replace("_", " ").title() or "Allgemein"), + "owner": item.get("owner") or _guess_owner(label, area), + } + + +def _guess_owner(label: str, area: str) -> str: + """Heuristik: wer ist der wahrscheinliche Ansprechpartner.""" + lab = label.lower() + if any(w in lab for w in ("banner", "cookie", "consent", + "einwilligung", "tracking")): + return "DSB + Marketing/CMP-Admin" + if any(w in lab for w in ("vendor", "avv", "auftragsverarbeitung", + "drittland", "schrems")): + return "DSB + Einkauf/Legal" + if any(w in lab for w in ("impressum", "agb", "widerruf", "kontakt")): + return "Legal + Web-Team" + if any(w in lab for w in ("dsfa", "dsr", "loeschfrist", "art. 15", + "auskunft", "betroffenenrecht")): + return "DSB" + if any(w in lab for w in ("tom", "verschluesselung", "backup", + "incident", "logging")): + return "IT-Security + DSB" + if area in ("banner", "cookie"): + return "DSB + Marketing" + return "DSB" + + +def _collect_top_findings( + banner_result: dict | None, + scorecard: dict | None, + library_mismatch_findings: list[dict] | None, + limit: int = 5, +) -> list[dict]: + out: list[dict] = [] + + # 1) Banner deep-check findings (HIGH zuerst) + if banner_result: + for ph in (banner_result.get("phases") or {}).values(): + if not isinstance(ph, dict): + continue + for f in (ph.get("findings") or []): + if not isinstance(f, dict): + continue + n = _normalize_finding({**f, "area": "banner"}) + if n: + out.append(n) + + # 2) Library-Mismatch HIGH (Marketing-Cookies als essential deklariert) + for mm in (library_mismatch_findings or []): + if isinstance(mm, dict) and mm.get("severity") == "HIGH": + out.append({ + "severity": "HIGH", + "label": f'Cookie "{mm.get("cookie","?")}" als ' + f'{mm.get("declared_category","?")} deklariert, ' + f'tatsaechlicher Zweck typischerweise ' + f'{mm.get("library_category","?")}', + "area": _AREA_LABEL["library_mismatch"], + "owner": "DSB + Marketing/CMP-Admin", + }) + + # 3) Scorecard FAILs (MC-Audit) + if scorecard: + for entry in (scorecard.get("failed") or scorecard.get("items") or []): + if not isinstance(entry, dict): + continue + n = _normalize_finding(entry) + if n and n["severity"] == "HIGH": + out.append(n) + + # Sort: HIGH first, then MEDIUM, stable order. Dedup by label. + seen: set[str] = set() + order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + out.sort(key=lambda f: order.get(f["severity"], 3)) + dedup: list[dict] = [] + for f in out: + key = f["label"].lower()[:80] + if key in seen: + continue + seen.add(key) + dedup.append(f) + if len(dedup) >= limit: + break + return dedup + + +def _score_color(score: float | int | None) -> str: + if score is None: + return "#64748b" + try: + s = float(score) + except (TypeError, ValueError): + return "#64748b" + if s >= 80: + return "#16a34a" + if s >= 60: + return "#ca8a04" + return "#dc2626" + + +def _delta_html(curr: float | None, prev: float | None) -> str: + if curr is None or prev is None: + return "" + try: + d = float(curr) - float(prev) + except (TypeError, ValueError): + return "" + if abs(d) < 0.5: + return ( + ' ' + '(unveraendert ggue. letztem Lauf)' + ) + arrow = "↑" if d > 0 else "↓" + color = "#16a34a" if d > 0 else "#dc2626" + return ( + f' ' + f'{arrow} {abs(d):.1f} Punkte ggue. letztem Lauf' + ) + + +def build_gf_one_pager_html( + site_name: str, + scorecard: dict | None = None, + previous_scorecard: dict | None = None, + banner_result: dict | None = None, + library_mismatch_findings: list[dict] | None = None, + scan_context: dict | None = None, +) -> str: + """5-7-Bullet-Zusammenfassung. Leere Top-Findings: nur Status-Bullet.""" + score = None + if scorecard: + score = scorecard.get("compliance_score") or scorecard.get("score") + prev_score = None + if previous_scorecard: + prev_score = (previous_scorecard.get("compliance_score") + or previous_scorecard.get("score")) + + top = _collect_top_findings( + banner_result=banner_result, + scorecard=scorecard, + library_mismatch_findings=library_mismatch_findings, + limit=5, + ) + + n_high = sum(1 for f in top if f["severity"] == "HIGH") + n_med = sum(1 for f in top if f["severity"] == "MEDIUM") + + if score is not None: + score_str = f'{float(score):.0f}/100' + else: + score_str = "—" + score_color = _score_color(score) + + ctx_line = "" + if scan_context: + bits: list[str] = [] + if scan_context.get("industry"): + bits.append(scan_context["industry"]) + if scan_context.get("business_model"): + bits.append(scan_context["business_model"].upper()) + if scan_context.get("employee_count"): + bits.append(f'{scan_context["employee_count"]} MA') + if bits: + ctx_line = ( + '
' + f'Klassifizierung: {" · ".join(bits)}' + '
' + ) + + bullets: list[str] = [] + sev_pill = { + "HIGH": 'HOCH', + "MEDIUM": 'MITTEL', + "LOW": 'NIEDRIG', + } + try: + from compliance.services.finding_confidence import confidence_pill_html + except Exception: + def confidence_pill_html(_label: str) -> str: + return "" + + for f in top: + bullets.append( + f'
  • ' + f'{sev_pill.get(f["severity"], "")} {f["area"]}: ' + f'{f["label"]}' + f'{confidence_pill_html(f["label"])} ' + f'— typisch zustaendig: ' + f'{f["owner"]}
  • ' + ) + + if not bullets: + bullets.append( + '
  • ' + 'Keine kritischen Themen erkannt — der Audit-Lauf hat fuer ' + 'die geprueften Dokumente keine HIGH-Findings produziert. ' + 'Details im weiteren Verlauf der Mail.
  • ' + ) + + return ( + '
    ' + '
    ' + f'Kurzfassung fuer die Geschaeftsfuehrung — {site_name or "—"}' + '
    ' + + ctx_line + + '
    ' + f'
    ' + f'{score_str}
    ' + '
    ' + f'Compliance-Score{_delta_html(score, prev_score)}
    ' + f'
    ' + f'{n_high} hoch · ' + f'{n_med} mittel' + '
    ' + '
    ' + 'Was kurzfristig angegangen werden sollte' + '
    ' + '
      ' + + "".join(bullets) + + '
    ' + '
    ' + 'Realistische Einordnung: Wir analysieren das ' + 'Aussenbild Ihrer Website automatisiert — einzelne Findings koennen ' + 'durch interne Dokumentation bereits abgedeckt sein. Empfohlenes ' + 'Vorgehen: priorisierte Punkte mit DSB / Marketing / IT in einem ' + 'Termin durchsprechen (4-8 Wochen sind ein realistischer Zeitrahmen ' + 'fuer die Umsetzung). Eine pauschale Bussgeld-Erwartung leiten wir ' + 'aus diesem Audit nicht ab.' + '
    ' + '
    ' + )