"""Mail-V2 Action library — turn findings into 'what to do where'. Each finding type maps to a concrete action recommendation. The mapping is intentionally pattern-matched (not LLM-generated): the audit is deterministic, so the corrective action must be too. Patterns matched by: - finding `id` prefix (mc-impressum-handelsregister → impressum/HR) - severity_reason (factually_wrong / missing / misclassified) - mismatch_type (dsi_under_actual / table_under_actual / ...) - cookie field name (country / duration / processing_company) Fallback: "Manuelle Prüfung beim DSB erforderlich" with finding hint. Returns an Action dict: - title: short imperative ("Sitzland ergänzen") - target: where to fix ("DSE / Vendor-Liste") - detail: extended explanation - aggregation_key: groupBy key for bulk recommendations ("missing_country" / "long_retention" / ...) - effort: "low" | "med" | "hi" """ from __future__ import annotations from dataclasses import asdict, dataclass @dataclass class Action: title: str target: str detail: str aggregation_key: str | None effort: str # low | med | hi def to_dict(self) -> dict: return asdict(self) # ── Field-level actions for cookie inventory ────────────────────── def cookie_field_missing_action(field: str, cookie_name: str, vendor: str) -> Action | None: """Return action when a cookie field is missing (or unknown).""" if field == "country": return Action( title="Sitzland ergänzen", target="DSE / Vendor-Tabelle", detail=(f"Für Cookie '{cookie_name}' (Vendor {vendor or '—'}) " "ist kein Sitzland der verarbeitenden Stelle angegeben. " "Art. 13 Abs. 1 lit. a DSGVO verlangt die Identität + " "Anschrift des Verantwortlichen."), aggregation_key="missing_country", effort="low", ) if field == "duration": return Action( title="Speicherdauer angeben", target="DSE / Cookie-Tabelle", detail=(f"Cookie '{cookie_name}' hat keine deklarierte " "Speicherdauer. Art. 13 Abs. 2 lit. a DSGVO verlangt " "die Dauer der Speicherung oder ein Kriterium dafür."), aggregation_key="missing_duration", effort="low", ) if field == "retention_grounds": return Action( title="Löschfrist + Rechtsgrundlage angeben", target="Löschkonzept + DSE", detail=(f"Für Cookie '{cookie_name}' fehlt eine konkrete " "Löschfrist. § 35 BDSG + DSK-Standard verlangen ein " "dokumentiertes Löschkonzept pro Datenkategorie."), aggregation_key="missing_retention", effort="med", ) if field == "processing_company": return Action( title="Verantwortliche Stelle nennen", target="DSE", detail=(f"Cookie '{cookie_name}' nennt keinen Verantwortlichen " "(Firma + Adresse). Art. 13 Abs. 1 DSGVO Pflichtangabe."), aggregation_key="missing_processing_company", effort="low", ) if field == "third_country": return Action( title="Drittlandtransfer absichern", target="DSE + AVV-Anhang", detail=(f"Cookie '{cookie_name}' (Vendor {vendor or '—'}) " "verarbeitet Daten außerhalb EU/EWR. Erforderlich: " "Angemessenheitsbeschluss, Standardvertragsklauseln " "oder ausdrückliche Einwilligung (Art. 44 ff. DSGVO)."), aggregation_key="missing_third_country", effort="med", ) if field == "category": return Action( title="Kategorie zuordnen", target="Cookie-Tabelle", detail=(f"Cookie '{cookie_name}' hat keine Kategorie. EDPB " "Cookie-Sweep verlangt: technisch notwendig / " "Statistik / Marketing / Externe Medien."), aggregation_key="missing_category", effort="low", ) return None # ── Status-level actions (UNDOC / ORPH / MISMATCH) ─────────────── def cookie_status_action(status_code: str, cookie_name: str, vendor: str) -> Action | None: if status_code == "UNDOC": return Action( title="Cookie deklarieren oder entfernen", target="CMP-Config + DSE", detail=(f"Cookie '{cookie_name}' wird im Browser gesetzt, ist " "aber nicht in DSE/Cookie-Tabelle deklariert. § 25 " "TDDDG: entweder Deklaration nachholen oder Cookie " "blockieren (CMP-Trigger prüfen)."), aggregation_key="undoc_cookies", effort="med", ) if status_code == "ORPH": return Action( title="Veraltete Cookie-Angabe entfernen", target="DSE / Cookie-Tabelle", detail=(f"Cookie '{cookie_name}' ist in DSE deklariert, wird " "aber im Live-Browser nicht gesetzt. Veraltete Angabe " "entfernen, um Transparenz zu wahren."), aggregation_key="orphan_cookies", effort="low", ) if status_code == "MISMATCH": return Action( title="Cookie-Werte korrigieren", target="DSE / Cookie-Tabelle", detail=(f"Cookie '{cookie_name}': deklarierte Werte weichen von " "tatsächlich gesetzten ab. Tabelle anpassen oder " "Cookie-Setup korrigieren."), aggregation_key="mismatch_cookies", effort="med", ) return None # ── Retention-comparison actions ───────────────────────────────── def retention_action(retention_finding: dict) -> Action | None: mt = retention_finding.get("mismatch_type") cookie = retention_finding.get("cookie_name", "—") if mt == "dsi_under_actual": return Action( title="DSE-Speicherdauer korrigieren", target="DSE", detail=(f"DSE behauptet für '{cookie}' kürzere Speicherdauer als " "real. Wert in DSE auf reale Dauer anpassen ODER Cookie-" "Setup auf deklarierte Dauer reduzieren."), aggregation_key="dsi_too_short", effort="low", ) if mt == "table_under_actual": return Action( title="Cookie-Tabelle korrigieren", target="Cookie-Tabelle / CMP", detail=(f"Cookie-Tabelle behauptet für '{cookie}' kürzere Dauer " "als real. Wert anpassen oder Cookie-Lifetime reduzieren."), aggregation_key="table_too_short", effort="low", ) if mt == "dsi_vs_table": return Action( title="DSE und Cookie-Tabelle synchronisieren", target="DSE + Cookie-Tabelle", detail=(f"DSE und Cookie-Tabelle geben unterschiedliche Werte " f"für '{cookie}' an. Werte abgleichen."), aggregation_key="dsi_table_mismatch", effort="low", ) if mt == "actual_under_table": return Action( title="Speicherdauer-Cap dokumentieren (Safari-ITP)", target="DSE", detail=(f"Cookie '{cookie}' lebt real kürzer als deklariert — " "wahrscheinlich Safari ITP 7-Tage-Cap. In DSE ergänzen: " "'Auf Safari-Geräten kann die Speicherdauer durch ITP " "verkürzt werden.'"), aggregation_key="safari_itp", effort="low", ) return None # ── Reachability actions (B1) ──────────────────────────────────── def reachability_action(rb1: dict) -> Action | None: if rb1.get("passed"): return None reason = rb1.get("severity_reason") if reason == "missing": return Action( title="Cookie-Einstellungen-Link im Footer ergänzen", target="Website-Footer (alle Seiten)", detail=("Art. 7 Abs. 3 DSGVO: Widerruf muss so einfach wie " "Erteilung sein. Footer-Link 'Cookie-Einstellungen' " "ergänzen, der den CMP direkt öffnet (kein neuer Tab, " "kein Zwischendokument)."), aggregation_key="footer_reachability", effort="low", ) if reason == "misclassified": return Action( title="CMP direkt öffnen statt neuer Tab", target="Footer-Link-Config", detail=("Bestehender Footer-Link öffnet die CMP nicht direkt. " "JavaScript-Trigger umstellen: kein target=_blank, " "keine externe Policy-Seite — CMP-Layer direkt öffnen."), aggregation_key="footer_reachability", effort="low", ) if reason == "factually_wrong": return Action( title="Eigenen CMP statt Browser-Verweis", target="Footer + CMP", detail=("Nutzer wird auf Browser-Einstellungen verwiesen — das " "ist nach LfDI BW kein gleichwertiger Widerruf. Eigenen " "CMP-Re-Open-Mechanismus implementieren."), aggregation_key="footer_reachability", effort="med", ) return None # ── Generic finding → action ──────────────────────────────────── _ID_PATTERNS = { "handelsregister": ("HR-Eintrag im Impressum ergänzen", "Impressum", "§ 5 Abs. 1 Nr. 4 TMG: Registereintrag mit " "Registergericht + HR-Nr."), "ust-id": ("USt-IdNr. ergänzen", "Impressum", "§ 5 Abs. 1 Nr. 6 TMG: USt-IdNr. falls vorhanden."), "vertretungsberechtig": ("Vertretungsberechtigte Person nennen", "Impressum", "§ 5 Abs. 1 Nr. 1 TMG"), "aufsichtsbehoerde": ("Aufsichtsbehörde nennen", "Impressum", "§ 5 Abs. 1 Nr. 3 TMG (regulierte Branchen)"), "berufsordnung": ("Berufsrechtliche Angaben ergänzen", "Impressum", "§ 5 Abs. 1 Nr. 5 TMG"), "dsb": ("DSB benennen", "DSE", "Art. 37 ff. DSGVO: Datenschutzbeauftragten benennen + DSE " "ergänzen."), "odr": ("OS-Link auf EU-Plattform ergänzen", "Impressum / AGB", "Art. 14 EU-VO 524/2013 (B2C-Onlineshop)"), "widerrufsbelehrung": ("Widerrufsbelehrung anpassen", "Widerruf-Dokument", "§ 312g BGB + Art. 246a EGBGB Muster-Widerrufs-" "belehrung."), } def derive_generic_action(finding_id: str, label: str, hint: str) -> Action | None: """Pattern-match a generic MC finding ID to an action template.""" fid = (finding_id or "").lower() haystack = f"{fid} {label.lower()}" for kw, (title, target, detail) in _ID_PATTERNS.items(): if kw in haystack: return Action( title=title, target=target, detail=detail + (f" Hinweis: {hint[:200]}" if hint else ""), aggregation_key=f"mc_{kw}", effort="low", ) if hint: return Action( title="Manuelle Prüfung beim DSB", target=label or "Doc", detail=hint[:400], aggregation_key=None, effort="med", ) return None def action_for_finding(finding_id: str, severity: str, label: str, hint: str) -> Action | None: """Top-level entry point for MC findings.""" return derive_generic_action(finding_id, label, hint)