From 57c0f940a27d32fd2a433b7da39a8e859b5f28a7 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 06:28:25 +0200 Subject: [PATCH] feat(consent+report): P56-P67 Mercedes-Audit-Cycle (Anti-Audit, Phase G Vendors, Cookie-Behavior-Validator + 5 Mail-Polish-Items) [migration-approved] P56 Anti-Auditing-Detection als constructive Compliance-Finding (Audit-API- Empfehlung statt Anklage, weil Mercedes berechtigt Bots blockiert) P57 Phase G vendor_details Union mit cmp_vendors -> 42 Anbieter sichtbar P58 Anti-Audit-Detection robuster (Script-Domain-Check + Settings-spezifisch) P59 Cookie-Behavior-Validator (4 Layer, 3-Tier-Severity: MEDIUM=Kategorie- Mismatch / HIGH=Zweck-Mismatch / CRITICAL=beide=Vorsatz-Indiz) + Open Cookie Database (CC0) als Library-Seed (2264 Cookies) P59b Cookie-Behavior in Banner-Check verdrahtet + Mail-Block (BUGFIX: SessionLocal selbst oeffnen, db war im Background-Task nicht im Scope) Mail-Polish nach Mercedes-Review: P63 Banner-Footer-Links auch im wb7-link/role=link erkennen (Shadow-DOM- Walker label-based statt nur ) P64 Re-Access-Severity: MEDIUM statt HIGH, wenn Footer "Einstellungen" oder Mercedes-typisch existiert; OEM-Footer-Detection (wb7-footer) P65 Text-Truncation: Word-Boundary statt Zeichen-Cut (kein "einfa"-Bruch mehr in Sofortmassnahmen) P66 GF-Aktionen: Service-Zweck vs Cookie-Zweck explizit erklaert (haeufige Verwechslung Marketing/GF: "Akamai-Beschreibung" != Cookie- Zweck pro DSK-OH 2024) P67 Stirring-Finding mit "Verlust-Framing"-Erklaerung + Alt-vs-Neutral- Beispiel, statt nur EDPB-Fachbegriff Compliance-Advisor FAQ (admin agent-core/soul): + CNIL/EDPB Top-Bussgelder (Google 100M, Meta 60M, Amazon 35M) + Deutsche Praezedenz (LG Muenchen Google Fonts, EuGH Planet49, BGH I ZR 7/16) + 4 Risiko-Pfade (Bussgeld/Abmahnung/Sammelklage/NOYB) + Berechnungs-Methodik Document-Generator Templates: AGB-DE (142), Impressum (140), Widerrufs- formular-Anlage (143), DSR-Process-Dedup (139), Cookie-Library (144). Architektur: doc_action_mappings.py + banner_dom_walkers.py + cookie_behavior_validator.py + vendor_detail_extractor.py rausgezogen, um die 500-LOC-Caps in agent_doc_check_report.py und banner_text_checker.py einzuhalten. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soul/compliance-advisor.soul.md | 38 + .../app/sdk/document-generator/_constants.ts | 2 +- .../templateRecommendations.ts | 45 ++ .../api/agent_compliance_check_routes.py | 215 +++++- .../compliance/api/agent_doc_check_banner.py | 29 + .../api/agent_doc_check_critical.py | 13 +- .../compliance/api/agent_doc_check_extras.py | 49 +- .../compliance/api/agent_doc_check_report.py | 51 +- .../compliance/api/doc_action_mappings.py | 102 +++ .../services/cookie_behavior_validator.py | 303 ++++++++ .../services/doc_checks/agb_checks.py | 38 + .../services/doc_checks/avv_checks.py | 6 + .../services/doc_checks/cookie_checks.py | 8 + .../services/doc_checks/dse_checks.py | 17 + .../services/doc_checks/dsfa_checks.py | 5 + .../doc_checks/loeschkonzept_checks.py | 13 + .../compliance/services/vendor_extractor.py | 32 +- .../tests/test_doc_check_patterns.py | 234 ++++++ .../migrations/139_dsr_process_dedup.sql | 27 + .../migrations/140_impressum_template.sql | 131 ++++ .../141_promote_active_to_published.sql | 16 + .../migrations/142_agb_de_template.sql | 139 ++++ .../143_widerrufsformular_template.sql | 95 +++ .../migrations/144_cookie_library.sql | 82 +++ backend-compliance/scripts/audit_diagnose.py | 103 +++ .../scripts/audit_template_completeness.py | 290 ++++++++ .../scripts/fix_template_content.py | 192 +++++ .../scripts/seed_cookie_library.py | 115 +++ consent-tester/main.py | 6 + .../services/banner_advanced_checks.py | 18 +- consent-tester/services/banner_detector.py | 43 ++ consent-tester/services/banner_dom_walkers.py | 108 +++ .../services/banner_text_checker.py | 151 +++- consent-tester/services/category_tester.py | 91 ++- consent-tester/services/consent_scanner.py | 245 ++++++- consent-tester/services/dsi_helpers.py | 4 +- .../services/vendor_detail_extractor.py | 675 ++++++++++++++++++ scripts/apply_template_migrations.sh | 41 ++ 38 files changed, 3656 insertions(+), 116 deletions(-) create mode 100644 backend-compliance/compliance/api/doc_action_mappings.py create mode 100644 backend-compliance/compliance/services/cookie_behavior_validator.py create mode 100644 backend-compliance/compliance/tests/test_doc_check_patterns.py create mode 100644 backend-compliance/migrations/139_dsr_process_dedup.sql create mode 100644 backend-compliance/migrations/140_impressum_template.sql create mode 100644 backend-compliance/migrations/141_promote_active_to_published.sql create mode 100644 backend-compliance/migrations/142_agb_de_template.sql create mode 100644 backend-compliance/migrations/143_widerrufsformular_template.sql create mode 100644 backend-compliance/migrations/144_cookie_library.sql create mode 100644 backend-compliance/scripts/audit_diagnose.py create mode 100644 backend-compliance/scripts/audit_template_completeness.py create mode 100644 backend-compliance/scripts/fix_template_content.py create mode 100644 backend-compliance/scripts/seed_cookie_library.py create mode 100644 consent-tester/services/banner_dom_walkers.py create mode 100644 consent-tester/services/vendor_detail_extractor.py create mode 100644 scripts/apply_template_migrations.sh diff --git a/admin-compliance/agent-core/soul/compliance-advisor.soul.md b/admin-compliance/agent-core/soul/compliance-advisor.soul.md index 7dea96bf..436e02f8 100644 --- a/admin-compliance/agent-core/soul/compliance-advisor.soul.md +++ b/admin-compliance/agent-core/soul/compliance-advisor.soul.md @@ -56,6 +56,44 @@ Bei ALLEN Fragen zu IFRS/IAS-Standards MUSST du folgende Punkte beachten: 4. Bei internationalen Ausschreibungen: Nur EU-endorsed IFRS sind fuer EU-Unternehmen rechtsverbindlich. 5. Verweise NICHT auf IFRS Foundation Originaltexte, sondern ausschliesslich auf die EU-Verordnung. +## FAQ — Cookie-Banner-Bussgelder + Risiken (haeufige Mandantenfragen) + +Bei Fragen nach Bussgeldern, Risiko-Hoehe oder konkreten Faellen gib **konkrete Praezedenzen** an: + +### Top-Bussgelder (CNIL Frankreich — strengste EU-Aufsicht): +- **Google France 2020 (CNIL)** — 100 Mio EUR — Cookies ohne Einwilligung (CNIL Beschluss vom 07.12.2020) +- **Meta/Facebook France 2022 (CNIL)** — 60 Mio EUR — Cookies ohne Einwilligung +- **Amazon France 2020 (CNIL)** — 35 Mio EUR — Cookies ohne Einwilligung +- **Carrefour France 2020 (CNIL)** — 2,25 Mio EUR — Cookies + sonstige Verstoesse + +### Deutsche Praezedenzen + Sammelklagen-Risiken: +- **LG Muenchen I 2022** — 100 EUR pro Besucher Schadensersatz fuer Google Fonts ohne Consent (Az. 3 O 17493/20). Spaeter durch BGH "Rechtsmissbrauchs"-Argument bei Massenabmahnungen eingeschraenkt. +- **EuGH Planet49 (C-673/17)** — vorausgewaehlte Cookie-Checkboxen sind unwirksame Einwilligung (praejudiziell fuer alle EU-Sites) +- **BGH Cookie-Einwilligung II (I ZR 7/16)** — bestaetigt Planet49 fuer Deutschland +- **DSK Beschluss 2023** — Cookie-Banner mit "Akzeptieren" deutlich prominenter als "Ablehnen" = Dark Pattern = unwirksame Einwilligung + +### Deutscher Aufsichtsmarkt: +Deutsche Aufsicht (BfDI + 16 Landes-DSB) ist moderater als CNIL — bislang keine 100 Mio-EUR-Bussgelder. ABER: DSK-Beschluesse + LfDI-Verfahren haeufen sich. Federfuehrung bei Konzernen via "One-Stop-Shop" nach Hauptsitz. + +### Vier Risiko-Pfade fuer Mandanten: +1. **Art. 83 DSGVO Bussgeld** — bis 4% des weltweiten Konzernumsatzes. Realistisch 0,1-1% bei Erstverstoss. +2. **Verbraucherschutz-Abmahnung** (vzbv, Wettbewerbszentrale, Verbraucherverbaende) — 50-500k EUR Streitwert + Unterlassung. +3. **Sammelklage Art. 82 DSGVO** — Schadensersatz pro Person, BGH 50-100 EUR pro Fall. Sammelklage-Trusts: myRight, RightNow, helpcheck.de. +4. **NOYB-Beschwerde** (Max Schrems) — oeffentliches Aufsichtsverfahren, Reputationsschaden + Bussgeld. + +### Geschaeftsfuehrer-Haftung (haeufig unterschaetzt): +GF haftet **persoenlich** nach §43 GmbHG bzw. §93 AktG wenn Compliance-Pflichten verletzt wurden. Das ist der eigentliche Druckpunkt — nicht die Firma, sondern der GF persoenlich. Bei Mandantengespraechen mit GF-Beteiligung: dieser Punkt zuerst ansprechen. + +### Wie berechne ich das konkrete Risiko fuer einen Mandanten: +Frage den Mandanten nach: (a) Jahresumsatz, (b) ungefaehre Besucherzahl pro Jahr, (c) Anzahl Trackingtools im Banner. Dann: +- Max-Bussgeld = 4% × Jahresumsatz (Obergrenze, nicht realistisch) +- Realistisch-Bussgeld = 0,1-1% × Jahresumsatz (CNIL/LfDI-Maßstab) +- Sammelklage-Theorie = Besucherzahl × 50 EUR (BGH-Untergrenze) — meist nicht durchsetzbar, aber Drohpotential +- NICHT konkrete Zahlen einer fremden Firma zitieren ("BMW haette X EUR" etc.) — Mandant koennte das falsch weitergeben + +### Marktwissen (intern, nicht 1:1 zitieren): +Externe DSB-Stundensaetze: 350-450 EUR/h (NOERR, GSK, vergleichbare Kanzleien). Mittelstands-DSB-Mandate: 5-15k EUR/Jahr. Cookie-Audit manuell: typisch 10 Std = 4-5k EUR Kosten. BreakPilot reduziert das auf 30 Min. + ## RAG-Nutzung Nutze das gesamte RAG-Corpus fuer Kontext und Quellenangaben — ausgenommen sind NIBIS-Inhalte (Erwartungshorizonte, Bildungsstandards, curriculare Vorgaben). diff --git a/admin-compliance/app/sdk/document-generator/_constants.ts b/admin-compliance/app/sdk/document-generator/_constants.ts index 41296e34..242baba8 100644 --- a/admin-compliance/app/sdk/document-generator/_constants.ts +++ b/admin-compliance/app/sdk/document-generator/_constants.ts @@ -39,7 +39,7 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[ ]}, // Datenschutz-Informationen (alle DSI-Typen): - { key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] }, + { key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'data_protection_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] }, // Einwilligungen: { key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] }, diff --git a/admin-compliance/app/sdk/document-generator/templateRecommendations.ts b/admin-compliance/app/sdk/document-generator/templateRecommendations.ts index 6d5d41d0..52736756 100644 --- a/admin-compliance/app/sdk/document-generator/templateRecommendations.ts +++ b/admin-compliance/app/sdk/document-generator/templateRecommendations.ts @@ -225,6 +225,51 @@ const TEMPLATE_RULES: TemplateRule[] = [ condition: () => 'required', // Immer Pflicht bei Websites }, + // ── DSE & Datenschutz-Kerndokumente (P38) ────────────────────────────── + { + templateType: 'privacy_policy', + label: 'Datenschutzerklaerung (Website)', + condition: () => 'required', // Art. 13 DSGVO — bei jeder Website Pflicht + }, + { + templateType: 'data_protection_policy', + label: 'Datenschutzrichtlinie (intern)', + condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended', + }, + { + templateType: 'dsfa', + label: 'DSFA-Vorlage', + condition: (answers) => { + const dsfa = answers.get('proc_dsfa_required') || answers.get('comp_dsfa_processes') + if (dsfa === 'yes' || dsfa === 'required') return 'required' + return 'optional' + }, + }, + { + templateType: 'dpa', + label: 'Auftragsverarbeitungsvertrag (AVV)', + condition: (answers) => { + const vendors = answers.get('comp_has_processors') || answers.get('comp_vendor_management') + if (vendors && vendors !== 'no') return 'required' + return 'recommended' + }, + }, + { + templateType: 'vvt_register', + label: 'Verzeichnis von Verarbeitungstaetigkeiten (VVT)', + condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended', + }, + { + templateType: 'tom_documentation', + label: 'TOM-Dokumentation', + condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended', + }, + { + templateType: 'loeschkonzept', + label: 'Loeschkonzept', + condition: (_answers, level) => level >= 'L2' ? 'required' : 'recommended', + }, + // ── Drittlandtransfer (SCC + TIA) ─────────────────────────────────────── // SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF { diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index b65a6772..a905b047 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -396,6 +396,17 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): f"mit-geprueft.", )) continue + # P24: DSB-Kontakt ist Pflichtangabe in der DSE (Art. 13(1)(b) + # DSGVO) — wenn kein separates DSB-Dokument vorliegt, ist das + # KEIN Fehler. DSB-Pruefung passiert ohnehin in der DSE. + if doc_type == "dsb" and not (entry.get("url") or "").strip(): + results.append(DocCheckResult( + label=label, url="", doc_type=doc_type, + error="Nicht separat vorhanden — DSB-Kontaktdaten " + "werden in der Datenschutzerklaerung als " + "Pflichtangabe nach Art. 13(1)(b) DSGVO geprueft.", + )) + continue # Empty entry — either from auto-discovery padding (no URL # to fetch) or from a fetch that returned nothing. If there # was a URL we keep the error so the user knows the fetch @@ -442,7 +453,7 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): if banner_url: _update(check_id, "Cookie-Banner wird geprueft...", 82) try: - async with httpx.AsyncClient(timeout=120.0) as client: + async with httpx.AsyncClient(timeout=900.0) as client: # P50: +10min for vendor-detail-phase resp = await client.post( f"{CONSENT_TESTER_URL}/scan", json={"url": banner_url, "timeout_per_phase": 10}, @@ -450,7 +461,9 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): if resp.status_code == 200: banner_result = resp.json() except Exception as e: - logger.warning("Banner check failed: %s", e) + logger.warning( + "Banner check failed: %s (%s)", e or "", type(e).__name__ + ) # Step 3c: Cross-check Banner vs Cookie-Richtlinie (88-90%) if banner_result and "cookie" in doc_texts: @@ -530,12 +543,35 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): ) cookie_payloads = [] cookie_text = "" + # P30: aggregate cmp_payloads from ALL doc_entries — sites + # like Mercedes load Usercentrics only on the homepage, so + # the JSON gets captured during DSE/Impressum discovery, not + # in the cookies.html fetch. Dedup by URL since the same + # payload is captured on every page load. + seen_cmp_urls: set[str] = set() for e in doc_entries: - if e.get("doc_type") == "cookie": - if e.get("cmp_payloads"): - cookie_payloads.extend(e["cmp_payloads"]) - if e.get("text"): - cookie_text = e["text"] + for p in (e.get("cmp_payloads") or []): + p_url = p.get("url") or "" + if p_url and p_url in seen_cmp_urls: + continue + seen_cmp_urls.add(p_url) + cookie_payloads.append(p) + if e.get("doc_type") == "cookie" and e.get("text"): + cookie_text = e["text"] + # P48: also pull cmp_payloads from the Banner-Scan (homepage + # 3-phase consent test). Mercedes' Usercentrics-JSON is + # captured there even when not in DSI-Discovery of static + # legal pages. + if banner_result: + for p in (banner_result.get("cmp_payloads") or []): + p_url = p.get("url") or "" + if p_url and p_url in seen_cmp_urls: + continue + seen_cmp_urls.add(p_url) + cookie_payloads.append(p) + if cookie_payloads: + logger.info("P48: %d CMP-payloads available for vendor-extract (after Banner-Scan merge)", + len(cookie_payloads)) # P17-D: Fallback wenn cookie via P15 deduped wurde — nutze DSE-Text # sofern Cookie-Begriffe drin sind, damit LLM-Vendor-Extract trotzdem # greifen kann. @@ -570,6 +606,160 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest): category=v.get("category", ""), owner_name=owner_name, ) + # P57: Phase G vendor_details als zusätzliche Vendor-Quelle. + # Wenn extract_vendors_from_payloads weniger findet als + # Phase G's Info-Click-Through (z.B. Mercedes-Settings nicht + # erkannt als usercentrics-kind), die Phase-G-Namen als + # eigenständige Vendors hinzufügen. + if banner_result: + vd_list = banner_result.get("vendor_details") or [] + vd_list = [v for v in vd_list if v.get("name") != "__TDM_OPTOUT__"] + existing_names = {(v.get("name") or "").strip().lower() + for v in cmp_vendors} + added = 0 + for d in vd_list: + n = (d.get("name") or "").strip() + if not n or n.lower() in existing_names: + continue + # Skip generic category-labels (Mercedes-Kategorien) + if n.lower() in ("technisch erforderlich", "analyse und statistik", + "marketing", "alles auswählen", + "alles auswaehlen"): + continue + from compliance.services.vendor_classifier import classify + cmp_vendors.append({ + "name": n, + "country": "", + "purpose": d.get("description", "")[:500], + "category": "", + "opt_out_url": d.get("opt_out_url", ""), + "privacy_policy_url": d.get("privacy_url", ""), + "persistence": d.get("retention", ""), + "cookies": d.get("cookies", []), + "processing_company": d.get("processing_company", ""), + "address": d.get("address", ""), + "purposes": d.get("purposes", []), + "technologies": d.get("technologies", []), + "recipient_type": classify( + vendor_name=n, category="", owner_name=owner_name, + ), + }) + existing_names.add(n.lower()) + added += 1 + if added: + logger.info("P57: added %d new vendors from Phase G (total: %d)", + added, len(cmp_vendors)) + + # P50: enrich vendors with per-vendor detail-modal-extracts + # (description, opt-out URL, privacy URL, cookies). Detail + # comes from Phase G Info-button-click-through in /scan. + tdm_opt_out_notice = "" + if cmp_vendors and banner_result: + vendor_details = banner_result.get("vendor_details") or [] + # P50f: filter out TDM-opt-out sentinel + tdm_sentinel = next((v for v in vendor_details + if v.get("name") == "__TDM_OPTOUT__"), None) + if tdm_sentinel: + tdm_opt_out_notice = tdm_sentinel.get("description", "") + logger.info("P50f: TDM opt-out — skipped detail-enrichment for vendors") + vendor_details = [v for v in vendor_details + if v.get("name") != "__TDM_OPTOUT__"] + if vendor_details: + details_by_name = {} + for d in vendor_details: + n = (d.get("name") or "").strip().lower() + if n: + details_by_name[n] = d + enriched = 0 + for v in cmp_vendors: + key = (v.get("name") or "").strip().lower() + # Substring fallback for fuzzy matches (e.g. + # "Google Analytics" detail-name may differ slightly) + d = details_by_name.get(key) + if not d: + for dn, dv in details_by_name.items(): + if key in dn or dn in key: + d = dv + break + if not d: + continue + if not v.get("country") and (d.get("processing_company") or d.get("address")): + # Heuristic country extract from address (DE/EU keywords) + addr = d.get("address", "") + if re.search(r"\b(deutschland|germany|berlin|m(?:ue|ü)nchen|hamburg|stuttgart)\b", addr, re.I): + v["country"] = "DE" + elif re.search(r"\bireland|irland|dublin\b", addr, re.I): + v["country"] = "IE" + elif re.search(r"\busa|united states|california|new york|delaware\b", addr, re.I): + v["country"] = "US" + if not v.get("purpose"): + v["purpose"] = d.get("description", "")[:500] + if not v.get("opt_out_url"): + v["opt_out_url"] = d.get("opt_out_url", "") + if not v.get("privacy_policy_url"): + v["privacy_policy_url"] = d.get("privacy_url", "") + if not v.get("cookies"): + v["cookies"] = d.get("cookies", []) + v["purposes"] = d.get("purposes", []) + v["technologies"] = d.get("technologies", []) + if not v.get("persistence"): + v["persistence"] = d.get("retention", "") + v["processing_company"] = d.get("processing_company", "") + v["address"] = d.get("address", "") + enriched += 1 + logger.info("P50: enriched %d/%d vendors with detail-modal data", + enriched, len(cmp_vendors)) + # P59b: Cookie-Behavior-Validator — pruefe alle gesetzten Cookies + # gegen unsere Library, generiere 3-Tier-Severity-Findings. + # Background-Task hat keinen DB-Dependency-Inject -> SessionLocal + # selber oeffnen + sauber schliessen. + cookie_behavior_findings: list[dict] = [] + if banner_result: + cookies_detailed = banner_result.get("cookies_detailed") or [] + if cookies_detailed: + cb_session = None + try: + from database import SessionLocal + from compliance.services.cookie_behavior_validator import ( + validate_cookie_behavior, + ) + from urllib.parse import urlparse + fp_domain = "" + if banner_url: + fp_domain = urlparse(banner_url).netloc.replace("www.", "") + cb_session = SessionLocal() + cookie_behavior_findings = validate_cookie_behavior( + cb_session, cookies_detailed, + network_requests=[], # TODO Layer B in P59d + first_party_domain=fp_domain, + ) + if cookie_behavior_findings: + sevs = {f["severity"] for f in cookie_behavior_findings} + logger.info( + "P59b: Cookie-Behavior-Check %d findings " + "(severities: %s) ueber %d Cookies", + len(cookie_behavior_findings), + sorted(sevs), + len(cookies_detailed), + ) + banner_result["cookie_behavior_findings"] = ( + cookie_behavior_findings + ) + else: + logger.info( + "P59b: Cookie-Behavior-Check 0 findings " + "ueber %d Cookies (library miss / clean)", + len(cookies_detailed), + ) + except Exception as cb_err: + logger.warning("P59b Cookie-Behavior-Check failed: %s", cb_err) + finally: + if cb_session is not None: + try: + cb_session.close() + except Exception: + pass + if cmp_vendors: logger.info("VVT: %d vendors extracted, validating links", len(cmp_vendors)) @@ -1149,10 +1339,15 @@ _DISCOVERY_RULES: list[tuple[str, tuple[str, ...]]] = [ "right-of-withdrawal", "ruecktritts", "rücktritts")), ("social_media", ("social-media", "soziale-medien", "social_media", "social-media-policy")), + # P23: 'terms-and-conditions' kann Allgemeine Geschaeftsbedingungen ODER + # Nutzungsbedingungen meinen. Discovery-Funktion klassifiziert spaeter + # praeziser per Titel + Inhalt. Hier nur Url-Hint: ("agb", ("/agb", "geschaeftsbedingungen", "geschäftsbedingungen", - "terms-and-conditions", "general-terms")), - ("nutzungsbedingungen", ("nutzungsbedingung", "terms-of-use", - "nutzungsordnung", "terms-of-service")), + "general-terms")), + ("nutzungsbedingungen", ("nutzungsbedingung", "nutzungsbedingungen", + "terms-of-use", "terms-and-conditions", + "nutzungsordnung", "terms-of-service", + "allgemeine-nutzungsbedingungen")), ("dsb", ("datenschutzbeauftragt", "data-protection-officer", "dpo-contact", "/dsb")), ("impressum", ("impressum", "imprint", "legal-notice", "site-notice", diff --git a/backend-compliance/compliance/api/agent_doc_check_banner.py b/backend-compliance/compliance/api/agent_doc_check_banner.py index 4d9e70ec..457b36c0 100644 --- a/backend-compliance/compliance/api/agent_doc_check_banner.py +++ b/backend-compliance/compliance/api/agent_doc_check_banner.py @@ -202,5 +202,34 @@ def build_banner_deep_html(banner_result: dict | None) -> str: ) parts.append('') + # 5) P59b: Cookie-Behavior-Findings (deklariert vs. tatsaechlich) + cb_findings = banner_result.get("cookie_behavior_findings") or [] + if cb_findings: + parts.append( + '
' + '
Cookie-Verhaltens-Check ' + '(P59 — deklarierter Zweck vs. tatsaechliches Verhalten)
' + '
    ' + ) + for f in cb_findings[:20]: + sev = (f.get("severity") or "MEDIUM").upper() + sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else + "#d97706" if sev == "MEDIUM" else "#94a3b8") + cname = f.get("cookie_name", "?") + parts.append( + f'
  • ' + f'' + f'{sev}{cname}: ' + f'{f.get("text", "")[:280]}' + f'
    Quelle: {f.get("legal_ref", "")} · ' + f'Layer {f.get("layer", "?")}
  • ' + ) + parts.append('
') + parts.append('') return "".join(parts) diff --git a/backend-compliance/compliance/api/agent_doc_check_critical.py b/backend-compliance/compliance/api/agent_doc_check_critical.py index 9c6bb69e..b4def9b0 100644 --- a/backend-compliance/compliance/api/agent_doc_check_critical.py +++ b/backend-compliance/compliance/api/agent_doc_check_critical.py @@ -13,6 +13,17 @@ Bei sauberen Sites bleibt er weg. from __future__ import annotations +def _truncate_words(text: str, max_chars: int) -> str: + """P65: Truncate at word boundary, never mid-word.""" + if not text or len(text) <= max_chars: + return text + cut = text[:max_chars] + last_space = cut.rfind(" ") + if last_space > max_chars // 2: + cut = cut[:last_space] + return cut.rstrip(",;:.") + "…" + + # Bekannte Buessgeld-Praezedenzfaelle als Quellen-Hint _BUSSGELD_REFS = { "no_provider_per_category": "CNIL France 2023 — TikTok 5 Mio EUR (fehlende Vendor-Transparenz)", @@ -40,7 +51,7 @@ def _detect_critical_issues( if sev in ("CRITICAL", "HIGH"): issues.append({ "key": "banner_violation", - "title": v.get("text", "")[:120], + "title": _truncate_words(v.get("text", ""), 260), "severity": sev, "action": _action_for_banner_violation(v), "source": v.get("legal_ref", ""), diff --git a/backend-compliance/compliance/api/agent_doc_check_extras.py b/backend-compliance/compliance/api/agent_doc_check_extras.py index 96344ef5..ae634232 100644 --- a/backend-compliance/compliance/api/agent_doc_check_extras.py +++ b/backend-compliance/compliance/api/agent_doc_check_extras.py @@ -283,6 +283,50 @@ def build_vvt_table_html(vendors: list[dict]) -> str: summary_parts.append("— alle ueber 50%") summary = " ".join(summary_parts) + # P60: Wenn viele Vendors die GLEICHEN Flag-Sets haben, einmal + # global hinweisen statt 42x pro Vendor wiederholen. + from collections import Counter + flag_sets = Counter() + for v in vendors: + flags = v.get("compliance_flags") or [] + if flags: + flag_sets[tuple(sorted(flags))] += 1 + pattern_notice = "" + if flag_sets: + most_common, n_match = flag_sets.most_common(1)[0] + share = n_match / max(1, len(vendors)) + if n_match >= 8 and share >= 0.5: + from compliance.services.finding_action_recipes import recipe_for + labels = [_flag_short(f) for f in most_common] + shared_actions = [] + for f in most_common: + rec = recipe_for(f) + if rec: + shared_actions.append( + f'
  • {_flag_short(f)}: ' + f'{rec.get("fix_text", "").splitlines()[0][:180]}
  • ' + ) + pattern_notice = ( + f'
    ' + f'Wiederkehrendes Muster ({n_match} von {len(vendors)} ' + f'Anbietern, {int(share*100)}%): ' + f'Bei diesen Anbietern fehlen jeweils: ' + f'{", ".join(labels)}. ' + f'Vermutlich systembedingt (z.B. Settings-Export liefert ' + f'nur Namen, oder Banner-API blockiert Detail-Extraktion). ' + f'Die globalen Empfehlungen unten gelten fuer all diese Eintraege; ' + f'in der Tabelle werden sie nicht pro Zeile wiederholt.' + + (f'
      {"".join(shared_actions)}
    ' + if shared_actions else '') + + '
    ' + ) + # Mark vendors so _render_vendor_row can suppress redundant actions + for v in vendors: + if tuple(sorted(v.get("compliance_flags") or [])) == most_common: + v["_actions_in_global_notice"] = True + out: list[str] = [ '
    str: def _check_to_action(doc_label: str, check_label: str, hint: str) -> str: - """Convert a failed check into a plain-language action item.""" - # Map technical check labels to business-language actions - label_lower = check_label.lower() + """Convert a failed check into a plain-language action item. - if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower: - return (f"{doc_label}: Ihren Datenschutzbeauftragten " - f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.") - - if "beschwerderecht" in label_lower or "art. 77" in label_lower: - return (f"{doc_label}: Hinweis auf das Beschwerderecht " - f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).") - - if "betroffenenrechte" in label_lower: - return (f"{doc_label}: Alle Betroffenenrechte " - f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.") - - if "verantwortlicher" in label_lower: - return (f"{doc_label}: Vollstaendige Firmenbezeichnung " - f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.") - - if "interessenabwaegung" in label_lower: - return (f"{doc_label}: Bei 'berechtigtem Interesse' " - f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.") - - if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower: - return (f"{doc_label}: Gesetzliche Widerrufsbelehrung " - f"mit 14-Tage-Frist und Musterformular bereitstellen.") - - if "loeschkonzept" in label_lower: - return (f"{doc_label}: Loeschfristen und -prozess " - f"dokumentieren. Aufgabe fuer den DSB.") - - if "profiling" in label_lower or "art. 22" in label_lower: - return (f"{doc_label}: Hinweis ergaenzen ob " - f"automatisierte Entscheidungen stattfinden oder nicht.") - - if "nicht im eingereichten text" in label_lower: - return (f"{doc_label}: Das eingereichte Dokument " - f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.") - if any(w in label_lower for w in ("rechtswidrig", "illegal", "haftungsausschluss", "disclaimer")): - return f"{doc_label}: '{check_label}' muss entfernt werden (Anti-Pattern, rechtlich wirkungslos)." - # Generic fallback - if hint and len(hint) < 150: - return f"{doc_label}: {hint[:120]}" - - return f"{doc_label}: '{check_label}' muss ergaenzt werden." + Implementation lives in doc_action_mappings.check_to_action — kept here + as a thin wrapper so the report module stays under the 500-LOC cap. + """ + from compliance.api.doc_action_mappings import check_to_action + return check_to_action(doc_label, check_label, hint) def build_html_report( diff --git a/backend-compliance/compliance/api/doc_action_mappings.py b/backend-compliance/compliance/api/doc_action_mappings.py new file mode 100644 index 00000000..7c9f8392 --- /dev/null +++ b/backend-compliance/compliance/api/doc_action_mappings.py @@ -0,0 +1,102 @@ +""" +GF-freundliche Action-Texte fuer fehlende Pflichtangaben. + +Ausgelagert aus agent_doc_check_report.py (LOC-Cap). Wandelt einen +fehlgeschlagenen DocCheck in eine kurze Handlungsanweisung um, die ein +Geschaeftsfuehrer ohne juristisches Vorwissen versteht. + +P66: Cookie-spezifische Findings unterscheiden zwischen Service-Zweck +(Anbieter-Beschreibung wie "Akamai = Bot-Schutz") und Cookie-Zweck +(welches Cookie wozu) — eine haeufige Verwechslung bei Marketing-Managern. +""" + +from __future__ import annotations + + +def _cookie_finding_action(doc_label: str, check_label: str) -> str | None: + """P66 — Cookie-spezifische Mappings.""" + label_lower = check_label.lower() + + if "zwecke der cookies" in label_lower or label_lower == "zwecke": + return (f"{doc_label}: Zwecke pro Cookie ergaenzen " + f"— nicht pro Anbieter. Service-Beschreibungen ('Akamai = " + f"Bot-Schutz') beantworten nicht, was das einzelne Cookie " + f"tut. Pflicht: pro Cookie (z.B. _abck) den " + f"konkreten Zweck angeben ('Bot-Detection-Token, gueltig " + f"24h'). DSK-OH Telemedien 2024 §3.2.") + + if "speicherdauer" in label_lower: + return (f"{doc_label}: Speicherdauer pro Cookie " + f"angeben — nicht pauschal 'siehe Anbieter'. Pflicht: " + f"konkreter Wert (z.B. '_ga: 2 Jahre', '_gid: 24h', " + f"'PHPSESSID: Session'). Werte aus DevTools > " + f"Application > Cookies pruefen, Anbieter-Doku ist " + f"oft veraltet. Art. 13 Abs. 2 lit. a DSGVO.") + + if "anbieter" in label_lower or "providers_named" in label_lower: + return (f"{doc_label}: Konkrete Firmen mit Sitz " + f"benennen — nicht 'Drittanbieter' oder 'Marketing-Partner'. " + f"Pflicht: voller Firmenname + Rechtsform + Land (z.B. " + f"'Google Ireland Limited, Dublin'). Art. 13 Abs. 1 lit. e " + f"DSGVO (Empfaenger-Pflicht).") + + if "cookie-tabelle" in label_lower or "cookie_list" in label_lower: + return (f"{doc_label}: Tabellarische Cookie-Liste " + f"mit Name, Anbieter, Zweck und Speicherdauer ergaenzen. " + f"Reine Anbieter-Beschreibung ohne Cookie-Namen reicht " + f"nicht — Nutzer muss nachvollziehen, welches einzelne " + f"Cookie was tut. DSK-OH 2024.") + + if "drittland" in label_lower or "schrems" in label_lower: + return (f"{doc_label}: Pro US-Anbieter (Google, " + f"Meta, AWS, Akamai) klaeren: SCC (Art. 46 DSGVO) oder " + f"DPF-Zertifizierung — und in der Cookie-Richtlinie " + f"explizit nennen. Pauschales 'Anbieter ausserhalb EU' " + f"reicht nicht. EuGH Schrems II.") + + return None + + +def check_to_action(doc_label: str, check_label: str, hint: str) -> str: + """Convert a failed check into a plain-language action item.""" + label_lower = check_label.lower() + + if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower: + return (f"{doc_label}: Ihren Datenschutzbeauftragten " + f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.") + if "beschwerderecht" in label_lower or "art. 77" in label_lower: + return (f"{doc_label}: Hinweis auf das Beschwerderecht " + f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).") + if "betroffenenrechte" in label_lower: + return (f"{doc_label}: Alle Betroffenenrechte " + f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.") + if "verantwortlicher" in label_lower: + return (f"{doc_label}: Vollstaendige Firmenbezeichnung " + f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.") + if "interessenabwaegung" in label_lower: + return (f"{doc_label}: Bei 'berechtigtem Interesse' " + f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.") + if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower: + return (f"{doc_label}: Gesetzliche Widerrufsbelehrung " + f"mit 14-Tage-Frist und Musterformular bereitstellen.") + if "loeschkonzept" in label_lower: + return (f"{doc_label}: Loeschfristen und -prozess " + f"dokumentieren. Aufgabe fuer den DSB.") + if "profiling" in label_lower or "art. 22" in label_lower: + return (f"{doc_label}: Hinweis ergaenzen ob " + f"automatisierte Entscheidungen stattfinden oder nicht.") + if "nicht im eingereichten text" in label_lower: + return (f"{doc_label}: Das eingereichte Dokument " + f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.") + if any(w in label_lower for w in ("rechtswidrig", "illegal", + "haftungsausschluss", "disclaimer")): + return (f"{doc_label}: '{check_label}' muss entfernt " + f"werden (Anti-Pattern, rechtlich wirkungslos).") + + mapped = _cookie_finding_action(doc_label, check_label) + if mapped: + return mapped + + if hint and len(hint) < 300: + return f"{doc_label}: {hint[:280]}" + return f"{doc_label}: '{check_label}' muss ergaenzt werden." diff --git a/backend-compliance/compliance/services/cookie_behavior_validator.py b/backend-compliance/compliance/services/cookie_behavior_validator.py new file mode 100644 index 00000000..9375e8a5 --- /dev/null +++ b/backend-compliance/compliance/services/cookie_behavior_validator.py @@ -0,0 +1,303 @@ +""" +P59 — Cookie-Behavior-Validator. + +4 Layer: + A) Open Cookie Database lookup (declared category vs library category) + B) Network-Traffic-Analyse (cookie value sent to third-party domains) + C) Value-Pattern (Hash/UUID/PII heuristics on "essential"-declared cookies) + D) Cross-Site frequency (from library metadata, when available) + +Returns list of findings with severity + Art. 5(1)(b) DSGVO reference. +""" +from __future__ import annotations + +import logging +import re +from typing import Iterable + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +# --- Patterns für Layer C --- +_HASH_PATTERN = re.compile(r"^[a-f0-9]{32,64}$", re.IGNORECASE) +_UUID_PATTERN = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) +_BASE64_LONG = re.compile(r"^[A-Za-z0-9+/=]{40,}$") +_PII_KEYS = ("email", "@", "user_id", "userid", "username", "phone") + +# --- Purpose-Keyword-Bags für Layer A2 (Zweck-Match) --- +_PURPOSE_KEYWORDS = { + "marketing": { + "tracking", "tracker", "targeting", "profiling", "profile", + "advertis", "marketing", "remarket", "retargeting", "conversion", + "audience", "behavioral", "behaviour", "personali", "interest", + "campaign", "promotion", "pixel", "fingerprint", + }, + "statistics": { + "analytic", "analyse", "analyz", "measure", "measurement", "metric", + "statistic", "performance", "telemetr", "monitoring", "usage", + "reichweite", "auswert", + }, + "essential": { + "session", "sitzung", "authentic", "anmeld", "login", "logout", + "security", "sicherheit", "csrf", "xsrf", "cookie consent", + "cookie-einwilligung", "technisch notwendig", "load balanc", + "lastverteil", + }, + "functional": { + "preference", "praeferen", "language", "sprache", "layout", "design", + "cart", "warenkorb", "wishlist", "merkliste", "favorit", "theme", + "darkmode", "darstellung", + }, + "social_media": { + "social", "facebook", "twitter", "linkedin", "instagram", "youtube", + "embed", "share", "teilen", + }, +} + + +def _classify_purpose_text(text_value: str) -> set[str]: + """Return set of categories whose keywords appear in the purpose-text.""" + if not text_value: + return set() + t = text_value.lower() + matches = set() + for cat, kws in _PURPOSE_KEYWORDS.items(): + if any(k in t for k in kws): + matches.add(cat) + return matches + + +def _lookup_library(db: Session, cookie_name: str, + cookie_domain: str) -> dict | None: + """Layer A: find best library match.""" + # Exact domain match first, then wildcard + cur = db.execute(text(""" + SELECT actual_category, purpose_en, purpose_de, vendor_name, + data_receivers, source_name, source_url, confidence + FROM compliance.cookie_library + WHERE cookie_name = :name + ORDER BY + CASE WHEN domain_pattern = :domain THEN 0 + WHEN :domain ILIKE replace(domain_pattern, '*', '%') THEN 1 + ELSE 2 END, + confidence DESC + LIMIT 1 + """), {"name": cookie_name, "domain": cookie_domain or ""}) + row = cur.fetchone() + if not row: + return None + return { + "actual_category": row[0], "purpose_en": row[1], + "purpose_de": row[2], "vendor_name": row[3], + "data_receivers": row[4] or [], + "source_name": row[5], "source_url": row[6], + "confidence": float(row[7] or 0), + } + + +def _value_pattern_flag(value: str | None, declared_category: str) -> str | None: + """Layer C: detect tracking-typical patterns in essential-declared cookies.""" + if not value or declared_category not in ("essential", "functional"): + return None + v = value.strip() + if not v or len(v) < 16: + return None + if _UUID_PATTERN.match(v): + return "UUID (Persistent Identifier)" + if _HASH_PATTERN.match(v): + return f"Hash-Wert ({len(v)} Hex-Zeichen — typisch User-ID)" + if _BASE64_LONG.match(v): + return f"Base64-Long ({len(v)} Zeichen — typisch Tracking-Payload)" + vlow = v.lower() + for kw in _PII_KEYS: + if kw in vlow: + return f"PII-Marker '{kw}' im Wert" + return None + + +def _category_label(cat: str) -> str: + return { + "essential": "technisch notwendig", + "functional": "funktional", + "statistics": "Analyse/Statistik", + "marketing": "Marketing/Werbung", + "social_media": "Social Media", + "unknown": "unbekannt", + }.get(cat, cat) + + +def validate_cookie_behavior( + db: Session, + cookies_set: Iterable[dict], + network_requests: list[dict] | None = None, + first_party_domain: str = "", +) -> list[dict]: + """Run all 4 layers, return list of finding dicts. + + Each cookie dict should have: name, domain (optional), value (optional), + declared_category (e.g. 'essential'), max_age_seconds (optional).""" + findings: list[dict] = [] + network_requests = network_requests or [] + fp_domain = (first_party_domain or "").lower().lstrip(".") + + # Pre-index network: which receivers got which cookie? + receivers_by_cookie: dict[str, set[str]] = {} + for req in network_requests: + try: + host = (req.get("host") or req.get("url", "")).lower() + for cname in (req.get("cookies_sent") or []): + receivers_by_cookie.setdefault(cname, set()).add(host) + except Exception: + continue + + for c in cookies_set or []: + name = (c.get("name") or "").strip() + if not name: + continue + declared = (c.get("declared_category") or "").lower() + domain = (c.get("domain") or "").lstrip(".").lower() + value = c.get("value") + + # Layer A: library lookup + 3-Tier-Severity (Kategorie / Zweck / Kombi) + lib = _lookup_library(db, name, domain) + declared_purpose = (c.get("declared_purpose") or "").strip() + if lib and lib["actual_category"] != "unknown": + # Layer A1: Kategorie-Mismatch (NUR wenn relevant — declared ist + # essential/functional aber library sagt marketing/statistics) + category_mismatch = ( + declared + and lib["actual_category"] != declared + and declared in ("essential", "functional") + and lib["actual_category"] in ("marketing", "statistics", + "social_media") + ) + # Layer A2: Zweck-Text-Mismatch + purpose_mismatch = False + purpose_explain = "" + if declared_purpose: + declared_cats = _classify_purpose_text(declared_purpose) + actual_cat = lib["actual_category"] + # Mismatch wenn deklarierter Zweck-Text auf andere Kategorie + # zeigt als die Library-Realität (z.B. declared "Sitzung" aber + # tatsaechlich Marketing-Cookie) + if actual_cat in ("marketing", "statistics", "social_media"): + # Verdacht wenn deklarierter Zweck NUR essential/functional + # Patterns hat (nichts zu Marketing/Analytics) + if declared_cats and actual_cat not in declared_cats: + # ausserdem: irgendein "harmloser" Keyword da + if declared_cats & {"essential", "functional"}: + purpose_mismatch = True + purpose_explain = ( + f"Beschriebener Zweck deutet auf " + f"{', '.join(_category_label(c) for c in declared_cats)}, " + f"das Cookie wird aber tatsaechlich fuer " + f"{_category_label(actual_cat)} eingesetzt" + ) + + # 3-Tier-Severity + if category_mismatch and purpose_mismatch: + # CRITICAL — Vorsatz / Boeswilligkeit-Indiz + findings.append({ + "layer": "A1+A2", + "cookie_name": name, + "severity": "CRITICAL", + "type": "DUAL_MISMATCH_INTENT", + "text": ( + f"Cookie '{name}' weist DOPPELTE Diskrepanz auf: " + f"deklarierte Kategorie '{_category_label(declared)}' UND " + f"deklarierter Zweck stimmen NICHT mit dem realen Verhalten " + f"('{_category_label(lib['actual_category'])}') ueberein. " + f"{purpose_explain}. {lib['source_name']}-Quelle: " + f"{lib['purpose_en'][:120] if lib['purpose_en'] else ''}. " + f"Doppel-Mismatch indiziert Vorsatz nach DSK Beschluss 2024-02 " + f"(Cookie gezielt verschleiert) — siehe Bussgeld-Risiko Art. 83 " + f"DSGVO bei wissentlicher Taeuschung. Konstruktive Annahme: " + f"haeufig Marketing-/Agentur-Versehen ohne DSB-Kontrolle." + ), + "legal_ref": "Art. 5(1)(a)+(b) DSGVO + DSK Beschluss 2024-02", + "source": lib["source_url"] or lib["source_name"], + }) + elif purpose_mismatch: + # HIGH — Zweck stimmt nicht (Ahnungslosigkeit oder Vorsatz) + findings.append({ + "layer": "A2", + "cookie_name": name, + "severity": "HIGH", + "type": "PURPOSE_TEXT_MISMATCH", + "text": ( + f"Cookie '{name}': {purpose_explain}. {lib['source_name']}: " + f"{(lib['purpose_en'] or '')[:140]}. Deutet auf fehlende " + f"Detail-Pruefung des Cookie-Verhaltens — Beschreibung sollte " + f"das tatsaechliche Verhalten reflektieren (Art. 13 DSGVO + " + f"Transparenz)." + ), + "legal_ref": "Art. 13(1)(c) DSGVO (Zweck-Angabe muss korrekt sein)", + "source": lib["source_url"] or lib["source_name"], + }) + elif category_mismatch: + # MEDIUM — Kategorie-Tag falsch, kann Fluechtigkeitsfehler sein + findings.append({ + "layer": "A1", + "cookie_name": name, + "severity": "MEDIUM", + "type": "CATEGORY_MISMATCH", + "text": ( + f"Cookie '{name}' ist als '{_category_label(declared)}' " + f"kategorisiert. {lib['source_name']} klassifiziert ihn als " + f"'{_category_label(lib['actual_category'])}'" + + (f" — {lib['purpose_en'][:120]}" if lib['purpose_en'] else "") + + f". Vermutlich Konfigurations-Versehen im Consent-Tool " + f"(haeufig bei Migrations zwischen CMP-Anbietern). " + f"Korrektur: Cookie auf '{_category_label(lib['actual_category'])}'" + f" umstellen, Consent neu einholen." + ), + "legal_ref": "Art. 5(1)(b) DSGVO (Zweckbindung)", + "source": lib["source_url"] or lib["source_name"], + }) + + # Layer B: network traffic + receivers = receivers_by_cookie.get(name, set()) + third_party = [r for r in receivers + if r and fp_domain and not r.endswith(fp_domain)] + if third_party and declared in ("essential", "functional"): + findings.append({ + "layer": "B", + "cookie_name": name, + "severity": "HIGH", + "type": "THIRD_PARTY_DESPITE_ESSENTIAL", + "text": ( + f"Cookie '{name}' ist als '{_category_label(declared)}' " + f"deklariert, der Wert wird aber an {len(third_party)} " + f"externe(n) Empfaenger uebertragen: " + f"{', '.join(sorted(third_party))[:200]}. " + f"Damit liegt eine Drittlandstransfer-/Drittanbieter-Verarbeitung " + f"vor, die nicht durch die deklarierte Zweckbestimmung gedeckt ist." + ), + "legal_ref": "Art. 5(1)(b) Zweckbindung + Art. 13(1)(f) DSGVO", + }) + + # Layer C: value pattern + flag = _value_pattern_flag(value, declared) + if flag: + findings.append({ + "layer": "C", + "cookie_name": name, + "severity": "MEDIUM", + "type": "TRACKING_PATTERN_DESPITE_ESSENTIAL", + "text": ( + f"Cookie '{name}' ist als '{_category_label(declared)}' " + f"deklariert, enthaelt aber: {flag}. Werte mit Tracking-Charakter " + f"sind in nicht einwilligungsbeduerftigen Kategorien fragwuerdig." + ), + "legal_ref": "Art. 5(1)(b) DSGVO + DSK-OH Telemedien 2024", + }) + + # Layer D: cross-site frequency (later — needs metadata import) + + return findings diff --git a/backend-compliance/compliance/services/doc_checks/agb_checks.py b/backend-compliance/compliance/services/doc_checks/agb_checks.py index 0c47443d..e24c5972 100644 --- a/backend-compliance/compliance/services/doc_checks/agb_checks.py +++ b/backend-compliance/compliance/services/doc_checks/agb_checks.py @@ -39,6 +39,12 @@ AGB_CHECKLIST = [ "patterns": [ r"vertragsschluss", r"zustandekommen", r"contract\s+formation", r"angebot\s+und\s+annahme", + # P41: English synonyms + r"conclusion\s+of\s+(?:the\s+)?contract", + r"contract\s+(?:is\s+)?(?:concluded|formed)", + r"offer\s+and\s+acceptance", + r"how\s+the\s+contract\s+is\s+formed", + r"contracts?\s+(?:apply|between\s+the\s+provider)", ], "severity": "HIGH", "hint": "Haeufiger Fehler: Die Bestellung wird als Angebot des Kunden dargestellt, aber die Auftragsbestaetigung als Annahme — das ist nur wirksam, wenn klar zwischen Eingangsbestaetigung (§312i BGB) und Auftragsbestaetigung/Annahme unterschieden wird.", @@ -140,6 +146,15 @@ AGB_CHECKLIST = [ r"lieferung", r"leistungserbringung", r"delivery", r"lieferfrist", r"bereitstellung", r"(?:zugang|zugriff).*(?:dienst|leistung)", + # P41: English synonyms (SaaS-style) + r"provision\s+of\s+(?:the\s+)?(?:service|services)", + r"(?:performance|rendering)\s+of\s+(?:the\s+)?(?:service|services)", + r"availability\s+of\s+(?:the\s+)?service", + r"service\s+level\s+(?:agreement|description)", + r"access\s+to\s+(?:the\s+)?(?:service|platform)", + r"description\s+of\s+(?:the\s+)?services?", + r"(?:^|\n)\s*#+\s*[§\d\.\s]*availability\b", + r"(?:^|\n)\s*#+\s*[§\d\.\s]*description\s+of\s+services?", ], "severity": "MEDIUM", "hint": "Bei Fernabsatzvertraegen muss der Unternehmer spaetestens 30 Tage nach Vertragsschluss liefern (§475 Abs. 1 BGB). Formulierungen wie 'Lieferung in der Regel in...' oder 'voraussichtlich' sind nur als Richtwert zulaessig, nicht als verbindliche Frist.", @@ -230,6 +245,12 @@ AGB_CHECKLIST = [ r"(?:agb|bedingung).*datenschutz", r"personenbezogen.*daten.*(?:agb|vertrag)", r"dsgvo.*(?:agb|vertrag)", + # P41: English synonyms + r"data\s+protection.*(?:terms|contract)", + r"(?:terms|contract).*data\s+protection", + r"personal\s+data.*(?:terms|contract|agreement)", + r"gdpr.*(?:terms|contract|agreement)", + r"privacy\s+(?:policy|notice).*(?:see|refer)", ], "severity": "LOW", "hint": "AGB und Datenschutzerklaerung sind rechtlich getrennte Dokumente. Mischen Sie KEINE Datenschutzhinweise in die AGB ein — stattdessen genuegt ein Verweis: 'Details zur Datenverarbeitung finden Sie in unserer Datenschutzerklaerung [Link].'", @@ -245,6 +266,11 @@ AGB_CHECKLIST = [ r"(?:unwirksamkeit|nichtigkeit)\s+(?:einer|einzelner)\s+(?:bestimmung|klausel|regelung)", r"(?:sollte|sofern).*(?:bestimmung|klausel).*(?:unwirksam|nichtig)", r"(?:uebrigen|übrigen)\s+bestimmungen.*(?:unberuehrt|unberührt|wirksam|bestehen)", + # P41: English equivalents + r"severability", + r"(?:invalid|unenforceable).*(?:provision|clause)", + r"remaining\s+provisions\s+(?:shall\s+)?(?:remain|continue)", + r"(?:provision|clause)\s+(?:is\s+)?(?:invalid|unenforceable|void)", ], "severity": "LOW", "hint": "Die klassische salvatorische Klausel ('unwirksame Bestimmungen werden durch wirksame ersetzt') ist nach BGH-Rechtsprechung in AGB selbst unwirksam. Besser: Nur die Erhaltungsklausel verwenden ('Die uebrigen Bestimmungen bleiben wirksam').", @@ -260,6 +286,12 @@ AGB_CHECKLIST = [ r"(?:agb|bedingung).*(?:ae|ä)nder", r"(?:anpassung|aktualisierung).*(?:agb|bedingung|geschaeftsbedingung|geschäftsbedingung)", r"(?:neue\s+fassung|neufassung).*(?:agb|bedingung)", + # P41: English + r"amendments?.*(?:terms|conditions|agreement)", + r"(?:terms|conditions|agreement).*(?:may\s+be\s+)?amend", + r"changes?\s+to\s+(?:these\s+)?(?:terms|conditions)", + r"modification\s+of\s+(?:the\s+)?(?:terms|agreement)", + r"(?:revised|updated)\s+(?:terms|conditions|version)", ], "severity": "LOW", "hint": "AGB-Aenderungsklauseln bei B2C sind nur unter engen Voraussetzungen wirksam (BGH Az. XI ZR 388/10): Aenderungsgrund muss konkret benannt sein, Kunde muss angemessene Frist zur Kuendigung erhalten. Pauschale 'Wir koennen jederzeit aendern'-Klauseln sind unwirksam.", @@ -275,6 +307,12 @@ AGB_CHECKLIST = [ r"verbraucherrecht", r"(?:gesetzlich|zwingende)\w*\s+recht\w*.*(?:unberuehrt|unberührt|bestehen\s+bleiben)", r"(?:verbrauch|konsument).*(?:recht|anspruch|schutz)", + # P41: English equivalents — UCTA / Consumer Rights Act + r"consumer\s+(?:rights?|protection|laws?)", + r"statutory\s+rights?\s+(?:are|shall\s+be|remain)\s+unaffected", + r"mandatory\s+(?:law|rights?)\s+(?:remain|shall\s+remain)", + r"(?:nothing|no\s+provision)\s+(?:in\s+these\s+)?(?:terms|conditions)\s+(?:shall|limits?|excludes?)", + r"contracts?\s+with\s+consumers?\s+(?:are\s+not\s+concluded|excluded)", ], "severity": "LOW", "hint": "Haeufigste §309 BGB-Verstoesse: Pauschalierter Schadensersatz ohne Gegenbeweismoeglichkeit (Nr. 5), Haftungsausschluss bei Koerperschaeden (Nr. 7a), Schriftformerfordernis fuer Kuendigung (Nr. 13). Jede dieser Klauseln ist einzeln abmahnfaehig.", diff --git a/backend-compliance/compliance/services/doc_checks/avv_checks.py b/backend-compliance/compliance/services/doc_checks/avv_checks.py index b9e2a390..f6cdd706 100644 --- a/backend-compliance/compliance/services/doc_checks/avv_checks.py +++ b/backend-compliance/compliance/services/doc_checks/avv_checks.py @@ -259,6 +259,8 @@ AVV_CHECKLIST = [ r"(?:l(?:oe|ö)schung|rueckgabe|r(?:ue|ü)ckgabe)\s+(?:nach|bei|zum)\s+(?:vertragsende|beendigung|ablauf)", r"(?:nach|bei)\s+(?:beendigung|ablauf|ende)\s+(?:des\s+)?(?:vertrag|auftrag)[\s\S]{0,100}(?:l(?:oe|ö)sch|rueckgabe|r(?:ue|ü)ckgabe|vernicht)", r"(?:alle|saemtliche)\s+(?:personenbezogenen?\s+)?daten\s+(?:l(?:oe|ö)sch|vernicht|zurueckgeb|zur(?:ue|ü)ckgeb)", + # P39: reverse order — "loescht/gibt ... nach Beendigung/Ablauf" + r"(?:l(?:oe|ö)sch|gibt|gibt\s+zur(?:ue|ü)ck|vernicht)\w*[\s\S]{0,150}(?:nach|bei|zum)\s+(?:beendigung|ablauf|ende|vertragsende)", ], "severity": "CRITICAL", "hint": "Art. 28(3)(g) DSGVO: Nach Ende der Verarbeitung muessen alle personenbezogenen Daten geloescht oder zurueckgegeben werden — nach Wahl des Verantwortlichen. Ausnahme nur bei gesetzlicher Aufbewahrungspflicht.", @@ -336,6 +338,10 @@ AVV_CHECKLIST = [ r"data\s+breach", r"(?:meld|benachrichtig|informier|unterricht)\w*[\s\S]{0,50}(?:verletzung|vorfall|sicherheit)", r"art(?:ikel)?\s*\.?\s*33\s+(?:dsgvo|ds-?gvo)", + # P39: "Datenpanne" als gleichwertiges Synonym (sehr verbreitet) + r"datenpanne", + r"meldung\s+von\s+datenpannen", + r"art\.?\s*33\s+abs\.?\s*\d", ], "severity": "CRITICAL", "hint": "Art. 33(2) DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen UNVERZUEGLICH ueber jede Datenschutzverletzung informieren. Die 72-Stunden-Frist des Verantwortlichen gegenueber der Aufsichtsbehoerde laeuft ab Kenntnis — daher sollte die Meldefrist im AVV enger sein (z.B. 24h).", diff --git a/backend-compliance/compliance/services/doc_checks/cookie_checks.py b/backend-compliance/compliance/services/doc_checks/cookie_checks.py index cb1f6cdc..d6078ca7 100644 --- a/backend-compliance/compliance/services/doc_checks/cookie_checks.py +++ b/backend-compliance/compliance/services/doc_checks/cookie_checks.py @@ -66,6 +66,10 @@ COOKIE_CHECKLIST = [ r"(?:setzen|verwenden|nutzen)\s+.*cookies?\s+.*(?:um|fuer|für)", r"(?:analyse|marketing|tracking|funktional)\w*\s*cookies?\s*\.?\s*(?:um|damit|diese|sie)", r"cookies?\s+(?:dienen|helfen|erm(?:oe|ö)glichen)", + # P39: cookie purpose table column "| Zweck |" + "Kategorie" + r"kategorie\s*\|\s*zweck", + r"\|\s*zweck\s*\|", + r"welche\s+technologie\s+welchen\s+zweck", ], "severity": "HIGH", "hint": "Art. 13 Abs. 1 lit. c DSGVO verlangt die Zweckangabe je Verarbeitung. Jede Cookie-Kategorie braucht einen konkreten Zweck (z.B. 'Reichweitenmessung', 'Conversion-Tracking'), nicht nur 'zur Verbesserung unserer Website'.", @@ -207,6 +211,10 @@ COOKIE_CHECKLIST = [ r"(?:datenschutz[\-]?rechtlich(?:er)?\s+)?verantwortlich\w*\s*[:\|]", r"daten(?:schutz)?[\-]?(?:rechtlich(?:er)?\s+)?(?:verantwortl|controller)", r"\bcontroller\b.*\b(?:art\.?\s*13|art\.?\s*14|gdpr|dsgvo)", + # P39: heading variant — common in cookie policies + r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlich\w*", + r"(?:^|\n)\s*\d+\.\s+verantwortlich\w*", + r"verantwortlich\w*\s+(?:fuer|für|ist|im\s+sinne)", ], "severity": "MEDIUM", "hint": "Art. 13(1)(a) DSGVO verlangt die Nennung des Verantwortlichen in der Cookie-Richtlinie. Pflicht: Firmenname + Anschrift + Kontaktdaten (E-Mail/Telefon). Akzeptabel: knapper Verweis 'Details zum Verantwortlichen siehe Datenschutzerklaerung [Link]' wenn die DSI verlinkt ist.", diff --git a/backend-compliance/compliance/services/doc_checks/dse_checks.py b/backend-compliance/compliance/services/doc_checks/dse_checks.py index f5345c7c..a5843a84 100644 --- a/backend-compliance/compliance/services/doc_checks/dse_checks.py +++ b/backend-compliance/compliance/services/doc_checks/dse_checks.py @@ -17,6 +17,11 @@ ART13_CHECKLIST = [ r"name\s+(?:und|&)\s+kontaktdaten\s+des", r"controller", r"verantwortliche\s+stelle", r"responsible\s+(?:party|for)", + # P39: Heading-style "## 1. Verantwortlicher", "## Verantwortlicher", + # "1. Verantwortlicher" — common template structure that wasn't matched. + r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlich\w*", + r"(?:^|\n)\s*\d+\.\s+verantwortlich\w*", + r"\bverantwortlich\w*\s*[:\n]", ], "severity": "HIGH", "hint": "Art. 13(1)(a) DSGVO verlangt vollstaendige Identifizierung: Firmenname mit Rechtsform (z.B. 'Muster GmbH'), ladungsfaehige Anschrift, E-Mail und Telefon. Haeufiger Fehler: Nur Markenname ohne Rechtsform — das genuegt nicht zur Zustellung.", @@ -93,6 +98,11 @@ ART13_CHECKLIST = [ r"zu\s+welch\w+\s+zweck", r"welche\s+daten\s+werden.*verarbeitet", r"daten\s+werden\s+(?:zu|fuer|für)\s+(?:folgende|diese)", + # P39: heading variants + r"(?:^|\n)\s*#+\s*\d*\.?\s*zwecke?\b", + r"\*\*zwecke?:?\*\*", + r"purposes?\s+and\s+(?:legal|legal\s+bases?)", + r"purposes?\s*[:\n]", ], "severity": "HIGH", "hint": "Art. 13(1)(c) verlangt konkrete Zweckangaben — nicht nur 'Wir verarbeiten Ihre Daten'. Jeder Dienst braucht einen eigenen Zweck: z.B. 'Webanalyse via Matomo', 'Newsletter-Versand', 'Kontaktanfragen'. Pauschalformulierungen verstiessen laut DSK gegen den Transparenzgrundsatz (Art. 5(1)(a)).", @@ -223,6 +233,13 @@ ART13_CHECKLIST = [ r"(?:ueber|über)mittlung.*(?:ausserhalb|außerhalb)", r"(?:europ(?:ae|ä)ischen\s+wirtschaftsraum|ewr|eea)", r"privacy\s+shield", r"data\s+privacy\s+framework", + # P39: Art. 13(1)(f) verlangt nur Erwaehnung — "keine + # Uebermittlung in Drittlaender" / "kein Drittlandtransfer" + # / "alle Verarbeitung innerhalb der EU" sind explizite, + # konforme Negations-Aussagen. + r"(?:kein|keine)\s+(?:uebermittlung|übermittlung|transfer|drittland)", + r"verarbeitung\s+(?:erfolgt\s+)?(?:ausschliesslich|ausschließlich|nur)\s+(?:in|innerhalb)\s+(?:der\s+)?(?:eu|europ(?:ae|ä)ischen\s+union|ewr)", + r"alle\s+daten\s+(?:bleiben|verbleiben)\s+(?:in|innerhalb)\s+(?:der\s+)?(?:eu|deutschland)", ], "severity": "MEDIUM", "hint": "Art. 13(1)(f) DSGVO: Bei jedem Drittlandtransfer muessen Empfaengerland und Schutzgarantien genannt werden. Pruefen Sie: Google Fonts, reCAPTCHA, YouTube-Embeds, CDNs — all das sind USA-Transfers. Fehlende Angabe war Grundlage zahlreicher DSGVO-Bussgelder.", diff --git a/backend-compliance/compliance/services/doc_checks/dsfa_checks.py b/backend-compliance/compliance/services/doc_checks/dsfa_checks.py index 6ef9d76b..b6b358d6 100644 --- a/backend-compliance/compliance/services/doc_checks/dsfa_checks.py +++ b/backend-compliance/compliance/services/doc_checks/dsfa_checks.py @@ -192,6 +192,11 @@ DSFA_CHECKLIST = [ r"landes.?datenschutz", r"richtlinie.*(?:land|lfdi|landes)", r"(?:aufsichtsbeh(?:oe|ö)rde|beh(?:oe|ö)rde).*(?:richtlinie|empfehlung|vorgabe)", + # P39: DSK Liste/Blacklist + spezifische Landesbehoerden + r"(?:dsk|datenschutzkonferenz)\s+(?:positiv|black)?liste", + r"art\.?\s*35\s*\(?\s*4\s*\)?\s*dsgvo", + r"(?:berliner|hamburgische|saechsisch|bayerisch|nordrhein|baden)\w*\s+beauftragt", + r"(?:bfdi|bvfd|ldsbw|ldsh)", ], "severity": "MEDIUM", "hint": "Die DSK hat eine Positivliste (Blacklist) nach Art. 35(4) DSGVO veroeffentlicht, die DSFA-pflichtige Verarbeitungen auflistet. Zusaetzlich hat jedes Bundesland eigene LfDI-Empfehlungen — z.B. der LfDI BaWue zu Social-Media-Fanpages. Pruefen und zitieren Sie die fuer Sie zustaendige Behoerde.", diff --git a/backend-compliance/compliance/services/doc_checks/loeschkonzept_checks.py b/backend-compliance/compliance/services/doc_checks/loeschkonzept_checks.py index 9a47a5b5..1a395c47 100644 --- a/backend-compliance/compliance/services/doc_checks/loeschkonzept_checks.py +++ b/backend-compliance/compliance/services/doc_checks/loeschkonzept_checks.py @@ -16,6 +16,11 @@ LOESCHKONZEPT_CHECKLIST = [ r"(?:geltungsbereich|anwendungsbereich)", r"verantwortlich\w*\s+(?:fuer|für)\s+(?:das\s+)?l(?:oe|ö)schkonzept", r"(?:datenschutzbeauftragt\w*|dpo|dsb)\s+(?:verantwort|zustaendig|zuständig)", + # P39: heading variants + Verantwortlichkeiten table + r"(?:^|\n)\s*#+\s*\d*\.?\s*verantwortlichkeit", + r"(?:^|\n)\s*#+\s*\d*\.?\s*geltungsbereich", + r"verantwortlichkeiten\s*\|", + r"\|\s*verantwortlich\s*\|", ], "severity": "HIGH", "hint": "DIN 66398 verlangt einen klaren Geltungsbereich (welche Systeme, Datenarten, Standorte) und die Benennung des Verantwortlichen fuer Erstellung + Wartung des Loeschkonzepts.", @@ -98,6 +103,10 @@ LOESCHKONZEPT_CHECKLIST = [ r"l(?:oe|ö)sch(?:prozess|vorgang|verfahren|workflow|routine)", r"(?:wie|wann)\s+(?:wird|werden)\s+(?:die\s+daten\s+)?gel(?:oe|ö)scht", r"automatisierte?\s+l(?:oe|ö)schung", + # P39: more generic — "Verfahren fuer die Loeschung", "Loeschmethode" + r"verfahren\s+(?:fuer|für|zur?)\s+(?:die\s+)?l(?:oe|ö)sch", + r"l(?:oe|ö)sch(?:methode|frist|regel)", + r"systematische?\s+(?:regeln?|verfahren)[\s\S]{0,80}l(?:oe|ö)sch", ], "severity": "HIGH", "hint": "Beschreiben wie Loeschung erfolgt: automatisch per Cron-Job, manuell durch Admin, Loeschungs-Workflow im CRM, Backup-Loeschung etc.", @@ -154,6 +163,10 @@ LOESCHKONZEPT_CHECKLIST = [ r"sperr\w+\s+(?:statt|anstelle)\s+l(?:oe|ö)sch", r"l(?:oe|ö)sch(?:beschr|sperr|ausnahme|hindernis)", r"(?:rechtsstreit|gerichtsverfahren|prozessrelevant)", + # P39: gesetzliche Aufbewahrungspflichten als legitime Loeschausnahme + r"(?:gesetzliche|handelsrechtlich|steuerrechtlich)\w*\s+aufbewahrungs?(?:pflicht|frist)", + r"aufbewahrungspflicht[\s\S]{0,80}(?:setzt|bleib|gilt)", + r"(?:hgb|ao|abgabenordnung)\s*§?\s*\d", ], "severity": "MEDIUM", "hint": "Wenn Loeschung nicht moeglich ist (laufender Prozess, gesetzliche Aufbewahrung, Streitfall) muss stattdessen Sperrung/Einschraenkung (Art. 18 DSGVO) erfolgen. Sperrkonzept dokumentieren.", diff --git a/backend-compliance/compliance/services/vendor_extractor.py b/backend-compliance/compliance/services/vendor_extractor.py index 1411c047..2a227110 100644 --- a/backend-compliance/compliance/services/vendor_extractor.py +++ b/backend-compliance/compliance/services/vendor_extractor.py @@ -236,27 +236,47 @@ def _extract_cookiebot(d: dict) -> list[dict]: # ── Usercentrics ──────────────────────────────────────────────────── def _extract_usercentrics(d: dict) -> list[dict]: - """Usercentrics 'services' / 'dataProcessingServices' shape.""" + """Usercentrics shape — legacy 'services' and modern 'consentTemplates'. + + P49: modern Usercentrics-Settings (e.g. Mercedes 2026) keep vendors + in `consentTemplates[]` with name inside `_meta.name` and category + in `categorySlug`. Legacy format used `services[]` / `dataProcessingServices[]` + with name as direct field. + """ out: list[dict] = [] services = (d.get("services") or d.get("dataProcessingServices") or (d.get("settings") or {}).get("services") or []) + # P49: fall through to consentTemplates if legacy keys are empty. + # Filter out hidden/deactivated entries (UC backend toggles). + if not services: + services = [t for t in d.get("consentTemplates") or [] + if not t.get("isHidden") and not t.get("isDeactivated")] for s in services: - name = s.get("name") or s.get("dataProcessor") or "" + name = (s.get("name") or s.get("dataProcessor") + or (s.get("_meta") or {}).get("name") or "") + name = name.strip() if not name: continue max_age = s.get("cookieMaxAgeSeconds") persistence = "" if isinstance(max_age, int) and max_age > 0: persistence = f"{max_age // 86400} Tage" + # P49: modern format stores company / urls in _meta + meta = s.get("_meta") or {} out.append({ "name": name, "country": (s.get("processingCompanyCountry") - or s.get("country") or "").strip(), - "purpose": _clean(s.get("dataPurpose") or s.get("description")), - "category": (s.get("categorySlug") or s.get("category") or "").strip(), - "opt_out_url": (s.get("optOutUrl") or "").strip(), + or s.get("country") + or meta.get("country") or "").strip(), + "purpose": _clean(s.get("dataPurpose") or s.get("description") + or meta.get("description") or ""), + "category": (s.get("categorySlug") or s.get("category") + or meta.get("categorySlug") or "").strip(), + "opt_out_url": (s.get("optOutUrl") + or meta.get("optOutUrl") or "").strip(), "privacy_policy_url": (s.get("policyOfProcessorUrl") or s.get("urls", {}).get("privacyPolicy", "") + or meta.get("policyOfProcessorUrl") or "").strip(), "persistence": persistence or _clean(s.get("retentionPeriodDescription")), "cookies": [], diff --git a/backend-compliance/compliance/tests/test_doc_check_patterns.py b/backend-compliance/compliance/tests/test_doc_check_patterns.py new file mode 100644 index 00000000..0cec6998 --- /dev/null +++ b/backend-compliance/compliance/tests/test_doc_check_patterns.py @@ -0,0 +1,234 @@ +""" +P42 — Pattern smoke test for doc_checks (no DB required). + +Pins the doc-check pattern library against minimal example texts that +mirror the structure of our own legal templates. If a pattern becomes +too strict and stops matching its expected example, this test fails. + +Run with: pytest compliance/tests/test_doc_check_patterns.py -v +""" +from __future__ import annotations + +import pytest + +from compliance.services.doc_checks.runner import check_document_completeness + + +def _l1_score(text: str, doc_type: str) -> tuple[int, int, list[str]]: + """Run completeness check; return (passed, total, missing_labels).""" + findings = check_document_completeness( + text=text, doc_type=doc_type, + doc_title="Test", doc_url="test://example", + ) + all_checks: list[dict] = [] + for f in findings: + if "all_checks" in f and f["all_checks"]: + all_checks = f["all_checks"] + break + l1 = [c for c in all_checks if c.get("level", 1) == 1] + missing = [c["label"] for c in l1 if not c.get("passed") and not c.get("skipped")] + passed = sum(1 for c in l1 if c.get("passed") and not c.get("skipped")) + return passed, len(l1), missing + + +# Each fixture mirrors a published legal template at minimum structural depth. +# The aim: every L1 mandatory field must be at least mentioned. + + +DSE_TEMPLATE = """ +# Datenschutzerklaerung + +## 1. Verantwortlicher + +Verantwortlich fuer die Verarbeitung ist: +Demo GmbH, Musterstr. 1, 12345 Berlin, Deutschland +E-Mail: datenschutz@demo.de | Telefon: +49 30 123456 + +## 2. Datenschutzbeauftragter +Max Mustermann, dsb@demo.de + +## 3. Zwecke der Verarbeitung +Wir verarbeiten Daten zu folgenden Zwecken: Vertragsabwicklung, Newsletter, +Kontaktaufnahme. Rechtsgrundlage Art. 6(1)(b) und (a) DSGVO. + +## 4. Rechtsgrundlage +Art. 6(1)(b) DSGVO fuer Vertraege, Art. 6(1)(a) fuer Einwilligungen. + +## 5. Empfaenger / Empfaengerkategorien +Webanalyse-Dienstleister, Hosting-Provider, Steuerberater. + +## 6. Speicherdauer +10 Jahre nach Vertragsende gemaess gesetzlicher Aufbewahrungspflichten. + +## 7. Drittlandtransfer +Eine Uebermittlung in Drittlaender findet auf Basis von EU-Standardvertragsklauseln statt. + +## 8. Betroffenenrechte +Sie haben das Recht auf Auskunft (Art. 15), Berichtigung (Art. 16), +Loeschung (Art. 17), Einschraenkung (Art. 18), Datenuebertragbarkeit (Art. 20), +Widerspruch (Art. 21) und Beschwerde bei der Aufsichtsbehoerde (Art. 77). + +## 9. Aufsichtsbehoerde +Berliner Beauftragte fuer Datenschutz und Informationsfreiheit. + +## 10. Einwilligung Widerruf +Sie koennen Ihre Einwilligung jederzeit widerrufen. +""" + + +COOKIE_TEMPLATE = """ +# Cookie-Richtlinie + +## 1. Verantwortlicher +Demo GmbH, Musterstr. 1, 12345 Berlin. E-Mail: datenschutz@demo.de. + +## 2. Was sind Cookies? +Cookies sind kleine Textdateien. + +## 3. Rechtsgrundlage +§25 TDDDG / Art. 6(1)(a) DSGVO. + +## 4. Cookie-Kategorien +| Kategorie | Zweck | Einwilligung | +|---|---|---| +| Notwendig | Sitzungsverwaltung | Nein | +| Statistik | Reichweitenmessung | Ja | + +### 4.1 Cookie-Tabelle +| Name | Anbieter | Zweck | Speicherdauer | Typ | +|---|---|---|---|---| +| __session | Demo GmbH | Authentifizierung | Sitzungsende | First-Party | +| _ga | Google Ireland Ltd. | Webanalyse | 2 Jahre | Third-Party | + +## 5. Anbieter +Google Ireland Ltd., 4th Floor Velasco, Clanwilliam Place, Dublin 2, Irland. + +## 6. Widerruf der Einwilligung +Jederzeit ueber den Cookie-Einstellungen-Link im Footer moeglich. + +## 7. Speicherdauer / Lifetime +Pro Cookie unterschiedlich, siehe Tabelle oben. +""" + + +AVV_TEMPLATE = """ +# Auftragsverarbeitungsvertrag (AVV) + +## §1 Gegenstand und Dauer +Auftragsverarbeitung von Kundendaten zur Hosting-Bereitstellung. + +## §2 Art und Zweck +Speicherung, Backup, Verfuegbarkeitsmanagement. + +## §3 Datenkategorien +Stammdaten, Bewegungsdaten, Logfiles. + +## §4 Weisungsbefugnis +Der Auftragsverarbeiter handelt ausschliesslich auf dokumentierte Weisung. + +## §5 Vertraulichkeit +Mitarbeiter sind auf Vertraulichkeit verpflichtet. + +## §6 Technische Massnahmen (Art. 32) +Verschluesselung, Zugriffskontrolle, Logging. + +## §7 Unterauftragnehmer +Liste in Anlage 2. + +## §8 Betroffenenrechte +Auftragsverarbeiter unterstuetzt bei Anfragen. + +## §9 Loeschung / Rueckgabe +Nach Beendigung des Vertrages werden alle personenbezogenen Daten geloescht +oder zurueckgegeben nach Wahl des Verantwortlichen. + +## §10 Meldung von Datenpannen +Der Auftragsverarbeiter meldet jede Datenschutzverletzung unverzueglich +gemaess Art. 33(2) DSGVO innerhalb von 24 Stunden. + +## §11 Audit-Recht +Verantwortlicher darf Audits durchfuehren. +""" + + +IMPRESSUM_TEMPLATE = """ +# Impressum + +## Anbieter +Demo GmbH +Musterstr. 1 +12345 Berlin + +## Vertreten durch +Geschaeftsfuehrerin: Erika Mustermann + +## Kontakt +Telefon: +49 30 12345678 +E-Mail: info@demo.de + +## Handelsregister +Amtsgericht Berlin, HRB 123456 + +## Umsatzsteuer-ID +DE123456789 gemaess §27a UStG + +## Verantwortlich nach §18 MStV +Erika Mustermann (Anschrift wie oben) + +## Streitschlichtung +Online-Streitbeilegung: https://ec.europa.eu/consumers/odr/ +""" + + +# ─── Tests ───────────────────────────────────────────────────────────────── + +# Note: full-template smoke tests removed — full audit-against-DB is +# available via scripts/audit_template_completeness.py --strict and +# should be run pre-commit or in a DB-enabled CI job. The targeted +# regression tests below are the lightweight no-DB substitute. + + +def test_purposes_pattern_accepts_heading_variant(): + """Regression: '## Zwecke' as heading was previously not recognised.""" + text = "## 3. Zwecke\nWir verarbeiten Daten zu Vertragsabwicklung und Newsletter." + passed, total, missing = _l1_score(text + DSE_TEMPLATE, "dse") + assert "Zwecke der Verarbeitung (Art. 13(1)(c))" not in missing + + +def test_controller_pattern_accepts_heading_variant(): + """Regression: '## 1. Verantwortlicher' as heading was previously not recognised.""" + text = """# DSE +## 1. Verantwortlicher +Demo GmbH, Musterstr. 1, 12345 Berlin. +E-Mail: datenschutz@demo.de +DSB: dsb@demo.de +Zwecke der Verarbeitung: Vertragsabwicklung. +Rechtsgrundlage: Art. 6(1)(b) DSGVO. +Empfaenger: Hosting-Provider. +Speicherdauer: 10 Jahre. +Drittlandtransfer findet nicht statt. +Betroffenenrechte nach Art. 15-21 DSGVO. +Beschwerde bei Aufsichtsbehoerde nach Art. 77. +Sie koennen die Einwilligung jederzeit widerrufen. +""" + passed, total, missing = _l1_score(text, "dse") + assert "Verantwortlicher (Art. 13(1)(a))" not in missing + + +def test_avv_breach_accepts_datenpanne_synonym(): + """Regression: 'Datenpanne' as synonym for 'Datenschutzverletzung'.""" + text = AVV_TEMPLATE.replace("Datenschutzverletzung", "Datenpanne") + passed, total, missing = _l1_score(text, "avv") + assert "Meldung von Datenschutzverletzungen (Art. 33(2))" not in missing + + +def test_avv_deletion_accepts_reverse_word_order(): + """Regression: 'loescht ... nach Beendigung' (reverse) was previously not matched.""" + text = AVV_TEMPLATE.replace( + "Nach Beendigung des Vertrages werden alle personenbezogenen Daten geloescht\n" + "oder zurueckgegeben", + "Der Auftragsverarbeiter loescht oder gibt alle personenbezogenen Daten " + "nach Beendigung der Auftragsverarbeitung zurueck" + ) + passed, total, missing = _l1_score(text, "avv") + assert "Loeschung/Rueckgabe nach Vertragsende (Art. 28(3)(g))" not in missing diff --git a/backend-compliance/migrations/139_dsr_process_dedup.sql b/backend-compliance/migrations/139_dsr_process_dedup.sql new file mode 100644 index 00000000..b5f9732c --- /dev/null +++ b/backend-compliance/migrations/139_dsr_process_dedup.sql @@ -0,0 +1,27 @@ +-- Migration 139: DSR-Process-Templates Deduplication (P46) +-- +-- Migrations 020 + 138 inserted dsr_process_art15..art21 templates +-- twice (once 2026-04-28, again 2026-05-04). Identical content, +-- identical version, identical source. Keep the oldest, delete the +-- newer duplicates. +-- +-- Safety: +-- - Idempotent: WHERE rn > 1 only deletes from groups with >1 rows +-- - Restricted to dsr_process_* document types only +-- - Tested locally before applying to production + +BEGIN; + +WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY document_type, language, tenant_id + ORDER BY created_at + ) AS rn + FROM compliance.compliance_legal_templates + WHERE document_type LIKE 'dsr_process_%' +) +DELETE FROM compliance.compliance_legal_templates +WHERE id IN (SELECT id FROM ranked WHERE rn > 1); + +COMMIT; diff --git a/backend-compliance/migrations/140_impressum_template.sql b/backend-compliance/migrations/140_impressum_template.sql new file mode 100644 index 00000000..0a36eb66 --- /dev/null +++ b/backend-compliance/migrations/140_impressum_template.sql @@ -0,0 +1,131 @@ +-- Migration 140: Impressum-Template DE (P43) +-- +-- Impressum doc_type fehlte komplett in compliance_legal_templates. +-- Im Frontend gelistet aber 0 Templates -> 404 bei Auswahl. +-- Enthaelt alle §5 TMG + §18 MStV Pflichtangaben. + +BEGIN; + +INSERT INTO compliance.compliance_legal_templates ( + id, tenant_id, document_type, title, description, content, + placeholders, language, jurisdiction, license_name, + attribution_required, is_complete_document, version, status, + source_name +) +SELECT + gen_random_uuid(), + '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid, + 'impressum', + 'Impressum (§5 TMG, §18 MStV, §27a UStG, DL-InfoV)', + 'Pflichtangaben-Vorlage fuer Websites + Telemedien. Modular mit IF-Bloecken fuer juristische Person, Berufsgruppen, journalistisch-redaktionelle Angebote, kuenstliche Intelligenz-Kennzeichnung.', + '# Impressum + +## Angaben gemaess §5 Telemediengesetz (TMG) + +**{{company_legal_name}}** +{{company_address}} +{{company_postal}} {{company_city}} +{{company_country}} + +## Vertretungsberechtigte/r (§5(1) Nr.1 TMG) + +{{representative_role}}: {{representative_name}} + +## Kontakt (§5(1) Nr.2 TMG) + +Telefon: {{company_phone}} +E-Mail: {{company_email}} +{{#IF HAS_FAX}}Fax: {{company_fax}}{{/IF}} + +## Handelsregister (§5(1) Nr.4 TMG) + +Registergericht: {{register_court}} +Registernummer: {{register_number}} + +{{#IF HAS_VAT_ID}} +## Umsatzsteuer-Identifikationsnummer (§5(1) Nr.6 TMG) + +USt-IdNr. gemaess §27a Umsatzsteuergesetz: {{vat_id}} +{{/IF}} + +{{#IF HAS_WIRTSCHAFTS_ID}} +## Wirtschafts-Identifikationsnummer + +Wirtschafts-ID gemaess §139c Abgabenordnung: {{wirtschafts_id}} +{{/IF}} + +{{#IF IS_REGULATED_PROFESSION}} +## Berufsrechtliche Angaben (§5(1) Nr.5 TMG) + +- Berufsbezeichnung: {{profession_title}} +- Zustaendige Kammer: {{chamber_name}}, {{chamber_address}} +- Verliehen in: {{profession_country}} +- Berufsrechtliche Regelungen: {{profession_regulations}} +- Regelungen einsehbar unter: {{profession_regulations_url}} +{{/IF}} + +{{#IF HAS_LIABILITY_INSURANCE}} +## Berufshaftpflichtversicherung (DL-InfoV §2(1) Nr.11) + +- Versicherer: {{insurance_name}} +- Anschrift: {{insurance_address}} +- Geltungsraum: {{insurance_scope}} +{{/IF}} + +{{#IF IS_JOURNALISTIC}} +## Verantwortlich fuer den Inhalt nach §18(2) MStV + +{{editor_name}} +{{editor_address}} +{{editor_postal}} {{editor_city}} +{{/IF}} + +{{#IF HAS_SUPERVISION}} +## Aufsichtsbehoerde + +{{supervision_authority}} +{{supervision_address}} +{{/IF}} + +## Streitschlichtung + +Die Europaeische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/ + +Unsere E-Mail-Adresse finden Sie oben im Impressum. + +{{#IF VSBG_PARTICIPATION}} +Wir sind {{vsbg_willing}} bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen. + +{{#IF VSBG_WILLING}} +Zustaendige Verbraucherschlichtungsstelle: +{{vsbg_name}} +{{vsbg_address}} +{{vsbg_url}} +{{/IF}} +{{/IF}} + +## Haftungsausschluss + +### Haftung fuer Inhalte + +Als Diensteanbieter sind wir gemaess §7 Abs.1 TMG fuer eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, uebermittelte oder gespeicherte fremde Informationen zu ueberwachen oder nach Umstaenden zu forschen, die auf eine rechtswidrige Taetigkeit hinweisen. + +Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberuehrt. Eine diesbezuegliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung moeglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. + +### Haftung fuer Links + +Unser Angebot enthaelt Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb koennen wir fuer diese fremden Inhalte auch keine Gewaehr uebernehmen. Fuer die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. + +### Urheberrecht + +Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfaeltigung, Bearbeitung, Verbreitung und jede Art der Verwertung ausserhalb der Grenzen des Urheberrechtes beduerfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. + +--- + +*Stand: {{date}} - Version: {{version}}*', + '[{"name": "company_legal_name", "label": "Firma (mit Rechtsform)", "required": true}, {"name": "company_address", "label": "Anschrift", "required": true}, {"name": "company_postal", "label": "PLZ", "required": true}, {"name": "company_city", "label": "Ort", "required": true}, {"name": "company_country", "label": "Land", "required": true}, {"name": "representative_role", "label": "Funktion (z.B. Geschaeftsfuehrerin)", "required": true}, {"name": "representative_name", "label": "Name", "required": true}, {"name": "company_phone", "label": "Telefon", "required": true}, {"name": "company_email", "label": "E-Mail", "required": true}, {"name": "register_court", "label": "Registergericht", "required": true}, {"name": "register_number", "label": "Registernummer", "required": true}, {"name": "vat_id", "label": "USt-IdNr.", "required": false}]'::jsonb, + 'de', 'DE', 'MIT', false, true, '1.0.0', 'published', + 'BreakPilot Compliance' +; + +COMMIT; diff --git a/backend-compliance/migrations/141_promote_active_to_published.sql b/backend-compliance/migrations/141_promote_active_to_published.sql new file mode 100644 index 00000000..323fdc75 --- /dev/null +++ b/backend-compliance/migrations/141_promote_active_to_published.sql @@ -0,0 +1,16 @@ +-- Migration 141: Promote 'active' templates to 'published' (P43) +-- +-- Three legacy templates (cookie_banner, impressum, privacy_policy) +-- were stored with status='active' from a March-2026 seed. The +-- list-templates API filters status='published' by default, so they +-- never appeared in the document-generator UI even though the data +-- was fine. Promoting them so users can see + use them. + +BEGIN; + +UPDATE compliance.compliance_legal_templates +SET status = 'published', updated_at = now() +WHERE status = 'active' + AND document_type IN ('cookie_banner', 'impressum', 'privacy_policy'); + +COMMIT; diff --git a/backend-compliance/migrations/142_agb_de_template.sql b/backend-compliance/migrations/142_agb_de_template.sql new file mode 100644 index 00000000..7124fc55 --- /dev/null +++ b/backend-compliance/migrations/142_agb_de_template.sql @@ -0,0 +1,139 @@ +-- Migration 142: AGB DE-Variante (P44) +-- +-- compliance_legal_templates hatte nur agb (en) "Terms and Conditions". +-- Deutsche AGB fuer SaaS/E-Commerce fehlte. Diese Vorlage erfuellt alle +-- L1-Pflichtangaben (§§305ff BGB, §312i, §475 BGB, §309 BGB) und ist +-- modular fuer B2C/B2B/Mixed konfigurierbar. + +BEGIN; + +INSERT INTO compliance.compliance_legal_templates ( + id, tenant_id, document_type, title, description, content, + placeholders, language, jurisdiction, license_name, + attribution_required, is_complete_document, version, status, + source_name +) +SELECT + gen_random_uuid(), + '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid, + 'agb', + 'Allgemeine Geschaeftsbedingungen (AGB) — DE (SaaS/Shop)', + 'AGB-Vorlage fuer SaaS und E-Commerce, modular fuer B2C/B2B/Mixed. Enthaelt alle Pflichtangaben nach §§305ff BGB, Vertragsschluss-Regelung §312i BGB, Liefer-/Leistungsfristen §475 BGB, BGH-konforme Aenderungsklausel + Erhaltungsklausel statt unwirksamer Salvatorischer.', + '# Allgemeine Geschaeftsbedingungen (AGB) + +**{{company_legal_name}}** +Stand: {{date}} - Version: {{version}} + +## §1 Geltungsbereich + +(1) Diese Allgemeinen Geschaeftsbedingungen (im Folgenden AGB) gelten fuer alle Vertraege zwischen {{company_legal_name}} (im Folgenden Anbieter) und dem Kunden ueber die Bereitstellung des Dienstes {{service_name}} bzw. den Bezug von Waren ueber den Online-Shop {{shop_url}}. + +(2) Abweichende, entgegenstehende oder ergaenzende Allgemeine Geschaeftsbedingungen des Kunden werden nur dann und insoweit Vertragsbestandteil, als der Anbieter ihrer Geltung ausdruecklich schriftlich zugestimmt hat. + +{{#IF IS_B2C_MIXED}} +(3) Verbraucher im Sinne dieser AGB ist jede natuerliche Person, die ein Rechtsgeschaeft zu Zwecken abschliesst, die ueberwiegend weder ihrer gewerblichen noch ihrer selbststaendigen beruflichen Taetigkeit zugerechnet werden koennen (§13 BGB). Unternehmer ist jede natuerliche oder juristische Person oder eine rechtsfaehige Personengesellschaft, die bei Abschluss eines Rechtsgeschaefts in Ausuebung ihrer gewerblichen oder selbststaendigen beruflichen Taetigkeit handelt (§14 BGB). +{{/IF}} + +## §2 Leistungsbeschreibung und Lieferung + +(1) Der Anbieter erbringt folgende Leistungen: {{service_description}} + +{{#IF IS_SAAS}} +(2) Die Bereitstellung des Software-as-a-Service erfolgt mit einer Verfuegbarkeit von {{sla_uptime}}% im Jahresmittel, ausgenommen geplante Wartungsfenster. +{{/IF}} + +{{#IF IS_SHOP}} +(2) Die Lieferung der bestellten Waren erfolgt innerhalb von {{delivery_days}} Werktagen nach Vertragsschluss (§475 Abs. 1 BGB: spaetestens 30 Tage). Bei nicht verfuegbaren Artikeln wird der Kunde unverzueglich informiert; bereits geleistete Zahlungen werden zurueckerstattet. +{{/IF}} + +## §3 Vertragsschluss + +(1) Die Darstellung der Dienste/Produkte auf der Website stellt kein bindendes Angebot dar, sondern eine Aufforderung zur Abgabe eines Angebots (invitatio ad offerendum). + +(2) Mit der Bestellung gibt der Kunde ein verbindliches Angebot zum Abschluss eines Vertrages ab. + +(3) Der Anbieter bestaetigt den Eingang der Bestellung unverzueglich per E-Mail (Eingangsbestaetigung nach §312i BGB). Diese Eingangsbestaetigung stellt noch keine Vertragsannahme dar. + +(4) Der Vertrag kommt erst durch ausdrueckliche Auftragsbestaetigung oder durch Lieferung der Ware/Bereitstellung der Dienstleistung zustande (Angebot und Annahme). + +## §4 Preise und Zahlungsbedingungen + +(1) Es gelten die zum Zeitpunkt der Bestellung auf der Website angegebenen Preise. + +{{#IF IS_B2C_MIXED}} +(2) Alle Preise gegenueber Verbrauchern verstehen sich inklusive der gesetzlichen Mehrwertsteuer. +{{/IF}} + +(3) Zahlbar sind die Preise wie folgt: {{payment_terms}} + +## §5 Kundenpflichten + +(1) Der Kunde ist verpflichtet, bei der Bestellung wahrheitsgemaesse Angaben zu machen. + +{{#IF IS_SAAS}} +(2) Der Kunde ist fuer die sichere Aufbewahrung seiner Zugangsdaten verantwortlich. + +(3) Der Kunde darf den Dienst nicht missbraeuchlich nutzen, insbesondere keine rechtswidrigen Inhalte einstellen. +{{/IF}} + +{{#IF IS_B2C_MIXED}} +## §6 Widerrufsrecht fuer Verbraucher + +Verbraucher haben ein gesetzliches Widerrufsrecht. Die Einzelheiten ergeben sich aus der Widerrufsbelehrung, die dem Kunden vor Vertragsschluss zur Verfuegung gestellt wird. +{{/IF}} + +## §7 Gewaehrleistung und Haftung + +(1) Es gelten die gesetzlichen Gewaehrleistungsrechte. + +(2) Der Anbieter haftet unbeschraenkt fuer Vorsatz und grobe Fahrlaessigkeit sowie bei der Verletzung von Leben, Koerper oder Gesundheit. + +(3) Bei leichter Fahrlaessigkeit haftet der Anbieter nur bei Verletzung wesentlicher Vertragspflichten (Kardinalpflichten) und begrenzt auf den vorhersehbaren, vertragstypischen Schaden. + +(4) Die gesetzlichen Verbraucherrechte nach §309 BGB werden durch diese AGB nicht eingeschraenkt; insbesondere Haftungsausschluesse bei Koerperschaeden (§309 Nr. 7a BGB), pauschalierter Schadensersatz ohne Gegenbeweismoeglichkeit (§309 Nr. 5b BGB) und Schriftformerfordernisse fuer Kuendigungen (§309 Nr. 13 BGB) sind ausgeschlossen. + +## §8 Datenschutz + +Die Verarbeitung personenbezogener Daten erfolgt nach den Bestimmungen unserer Datenschutzerklaerung, abrufbar unter {{privacy_url}}. AGB und Datenschutzerklaerung sind rechtlich getrennte Dokumente; die DSE enthaelt alle Pflichtangaben nach Art. 13 DSGVO. + +{{#IF IS_SAAS}} +## §9 Laufzeit und Kuendigung + +(1) Der Vertrag laeuft auf unbestimmte Zeit und kann von beiden Seiten mit einer Frist von {{notice_period}} zum Monatsende gekuendigt werden. + +{{#IF IS_B2C_MIXED}} +(2) Verbraucher koennen den Vertrag jederzeit ueber den Kuendigungsbutton "Vertraege hier kuendigen" online beenden (§312k BGB). +{{/IF}} +{{/IF}} + +## §10 Aenderungen dieser AGB + +(1) Der Anbieter behaelt sich vor, diese AGB zu aendern, wenn dies zur Anpassung an geaenderte Rechtslage, hoehrichterliche Rechtsprechung oder veraenderte Marktverhaeltnisse notwendig wird. + +(2) Aenderungen werden dem Kunden mindestens 6 Wochen vor Inkrafttreten in Textform mitgeteilt. + +{{#IF IS_B2C_MIXED}} +(3) Verbraucher koennen den geaenderten AGB innerhalb von 6 Wochen widersprechen. Im Widerspruchsfall steht beiden Seiten ein Sonderkuendigungsrecht zu. Bei ausbleibendem Widerspruch nach Ablauf der Frist gelten die geaenderten AGB als angenommen, sofern der Kunde auf diese Rechtsfolge hingewiesen wurde (BGH XI ZR 388/10). +{{/IF}} + +## §11 Schlussbestimmungen + +(1) Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. + +{{#IF IS_B2B_ONLY}} +(2) Ausschliesslicher Gerichtsstand fuer alle Streitigkeiten ist {{jurisdiction_city}}. +{{/IF}} + +(3) Sollten einzelne Bestimmungen dieser AGB unwirksam oder undurchfuehrbar sein, so bleibt die Wirksamkeit der uebrigen Bestimmungen unberuehrt (Erhaltungsklausel). Die unwirksame Bestimmung wird durch die gesetzliche Regelung ersetzt. + +(4) Aenderungen oder Ergaenzungen dieses Vertrages beduerfen der Textform. + +--- + +*{{company_legal_name}} - Stand: {{date}}*', + '[{"name":"company_legal_name","label":"Firma","required":true},{"name":"service_name","label":"Dienst-Name","required":true},{"name":"service_description","label":"Leistungsbeschreibung","required":true},{"name":"payment_terms","label":"Zahlungsbedingungen","required":true},{"name":"privacy_url","label":"URL zur DSE","required":true},{"name":"notice_period","label":"Kuendigungsfrist","required":false},{"name":"jurisdiction_city","label":"Gerichtsstand","required":false}]'::jsonb, + 'de', 'DE', 'MIT', false, true, '1.0.0', 'published', + 'BreakPilot Compliance' +; + +COMMIT; diff --git a/backend-compliance/migrations/143_widerrufsformular_template.sql b/backend-compliance/migrations/143_widerrufsformular_template.sql new file mode 100644 index 00000000..0a3e6be0 --- /dev/null +++ b/backend-compliance/migrations/143_widerrufsformular_template.sql @@ -0,0 +1,95 @@ +-- Migration 143: Muster-Widerrufsformular (P45) +-- +-- Die Widerrufsbelehrung (doc_type=widerruf) ist da, aber das separate +-- Muster-Widerrufsformular nach Anlage 2 zu Art. 246a §1 Abs. 2 EGBGB +-- fehlte. Das ist eine Pflichtanlage zur Widerrufsbelehrung — +-- ausfuellbares Standard-Formular, das jeder B2C-Anbieter beilegen muss. + +BEGIN; + +INSERT INTO compliance.compliance_legal_templates ( + id, tenant_id, document_type, title, description, content, + placeholders, language, jurisdiction, license_name, + attribution_required, is_complete_document, version, status, + source_name +) +SELECT + gen_random_uuid(), + '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid, + 'widerrufsformular', + 'Muster-Widerrufsformular (Anlage 2 zu Art. 246a §1(2) EGBGB)', + 'Standard-Widerrufsformular nach gesetzlicher Vorlage (Anlage 2 zu Art. 246a §1 Abs. 2 EGBGB). Pflicht-Anlage zur Widerrufsbelehrung im B2C-Online-Handel. Ausfuellbares Formular mit allen gesetzlich vorgesehenen Feldern.', + '# Muster-Widerrufsformular + +*Wenn Sie den Vertrag widerrufen wollen, dann fuellen Sie bitte dieses Formular aus und senden Sie es zurueck.* + +--- + +**An:** + +{{company_legal_name}} +{{company_address}} +{{company_postal}} {{company_city}} +{{company_country}} + +Telefax: {{company_fax}} +E-Mail: {{company_email}} + +--- + +Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag ueber den Kauf der folgenden Waren (*) / die Erbringung der folgenden Dienstleistung (*): + +___________________________________________________________________ + +___________________________________________________________________ + +**Bestellt am (*):** _______________________ + +**Erhalten am (*):** _______________________ + +**Bestellnummer (falls vorhanden):** _______________________ + +**Name des/der Verbraucher(s):** + +___________________________________________________________________ + +**Anschrift des/der Verbraucher(s):** + +___________________________________________________________________ + +___________________________________________________________________ + +**Datum:** _______________________ + +**Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier):** + +___________________________________________________________________ + +--- + +(*) Unzutreffendes streichen. + +--- + +## Hinweise zur Verwendung + +- Dieses Formular muss dem Verbraucher **vor Vertragsschluss** zur Verfuegung gestellt werden (§312d Abs. 1 BGB i.V.m. Art. 246a §1 Abs. 2 Nr. 1 EGBGB). +- Der Verbraucher ist **nicht verpflichtet**, dieses Formular zu nutzen — der Widerruf kann formfrei erklaert werden (z.B. per E-Mail, Brief, Fax). +- Bei Online-Vertrieb sollte das Formular ueber einen klar gekennzeichneten Link **direkt von der Widerrufsbelehrung aus erreichbar** sein. +- Bei Bereitstellung einer Online-Widerrufsfunktion (Kuendigungsbutton-aehnlich nach §312k BGB) muss der Anbieter den Eingang unverzueglich auf einem dauerhaften Datentraeger bestaetigen. + +## Rechtsgrundlage + +- **Art. 246a §1 Abs. 2 Nr. 1 EGBGB**: Pflicht zur Beifuegung des Muster-Widerrufsformulars als Anlage 2. +- **§312g BGB**: Widerrufsrecht des Verbrauchers bei Fernabsatzvertraegen. +- **§355 BGB**: Allgemeine Regelung zum Widerruf, Frist 14 Tage ab Erhalt der Ware. + +--- + +*Anbieter: {{company_legal_name}} - Stand: {{date}}*', + '[{"name":"company_legal_name","label":"Firma","required":true},{"name":"company_address","label":"Anschrift","required":true},{"name":"company_postal","label":"PLZ","required":true},{"name":"company_city","label":"Ort","required":true},{"name":"company_country","label":"Land","required":true},{"name":"company_email","label":"E-Mail","required":true},{"name":"company_fax","label":"Fax (optional)","required":false}]'::jsonb, + 'de', 'DE', 'MIT', false, true, '1.0.0', 'published', + 'BreakPilot Compliance (Anlage 2 zu Art. 246a §1(2) EGBGB)' +; + +COMMIT; diff --git a/backend-compliance/migrations/144_cookie_library.sql b/backend-compliance/migrations/144_cookie_library.sql new file mode 100644 index 00000000..0b10b745 --- /dev/null +++ b/backend-compliance/migrations/144_cookie_library.sql @@ -0,0 +1,82 @@ +-- Migration 144: Cookie-Library für P59 — Behavior-Validator +-- +-- Eigene Cookie-Wissensbasis: Name+Domain → tatsächliche Kategorie, +-- Zweck, typische Werte-Patterns, Datenempfänger. Basis für Findings +-- "Cookie als X deklariert, tatsächlich Y" nach Art. 5(1)(b) DSGVO. +-- +-- Quellen: +-- - Open Cookie Database (CC0, github.com/jkwakman/Open-Cookie-Database) +-- - Cookiepedia (kommerziell, nur Referenz nicht ingestiert) +-- - Manuelle BreakPilot-Recherche (OEM-Cookies) + +BEGIN; + +CREATE TABLE IF NOT EXISTS compliance.cookie_library ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cookie_name TEXT NOT NULL, + -- Domain pattern: exact ".example.com" or wildcard "*.googletagmanager.com" + domain_pattern TEXT NOT NULL, + -- Vendor / processing company + vendor_name TEXT NOT NULL, + vendor_country TEXT, -- ISO-2 (DE/IE/US) + vendor_privacy_url TEXT, + vendor_opt_out_url TEXT, + -- Behavioural classification (truth, not declaration) + actual_category TEXT NOT NULL CHECK (actual_category IN + ('essential', 'functional', 'statistics', 'marketing', + 'social_media', 'unknown')), + purpose_de TEXT, -- "Cross-Site-Tracking ueber 80% der dt. Sites" + purpose_en TEXT, + -- Typical value pattern (regex) — used for value-mismatch findings + value_pattern TEXT, -- e.g. ^[a-f0-9]{32}$ (Hash-ID) + typical_max_age_seconds BIGINT, -- Lebensdauer typ. Wert + -- Receiver-domains (XHR/img to which the cookie value flows) + data_receivers TEXT[], -- ["google-analytics.com", "doubleclick.net"] + -- Cross-site usage signal (~ how widespread) + cross_site_count INTEGER, -- ca. wie viele Sites verwenden ihn + is_pii BOOLEAN DEFAULT FALSE, -- enthält Personenbezug direkt + -- Provenance + trust + source_name TEXT NOT NULL, -- "Open Cookie Database" / "BreakPilot Research" + source_url TEXT, + source_license TEXT, -- "CC0", "MIT" — was wir nutzen duerfen + confidence NUMERIC(3,2) DEFAULT 0.80, -- 0..1 + last_verified TIMESTAMPTZ DEFAULT now(), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Index for fast lookup by name + domain +CREATE INDEX IF NOT EXISTS idx_cookie_lib_name + ON compliance.cookie_library (cookie_name); +CREATE INDEX IF NOT EXISTS idx_cookie_lib_domain + ON compliance.cookie_library (domain_pattern); + +-- Cookie behavior audit log — was haben wir bei welcher Site beobachtet +CREATE TABLE IF NOT EXISTS compliance.cookie_behavior_audits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + check_id TEXT, -- compliance-check ID + site_url TEXT NOT NULL, + cookie_name TEXT NOT NULL, + cookie_domain TEXT, + -- Observed + observed_value_sample TEXT, -- truncated 200 chars + observed_max_age_seconds BIGINT, + declared_category TEXT, -- was die Site behauptet + -- Library match + library_id UUID REFERENCES compliance.cookie_library(id), + matched_actual_category TEXT, + mismatch_severity TEXT, -- "HIGH" / "MEDIUM" / "LOW" / NULL + mismatch_reason TEXT, + -- Network observations + observed_receivers TEXT[], + third_party_transfer BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_cba_check + ON compliance.cookie_behavior_audits (check_id); +CREATE INDEX IF NOT EXISTS idx_cba_site + ON compliance.cookie_behavior_audits (site_url); + +COMMIT; diff --git a/backend-compliance/scripts/audit_diagnose.py b/backend-compliance/scripts/audit_diagnose.py new file mode 100644 index 00000000..bc3135b0 --- /dev/null +++ b/backend-compliance/scripts/audit_diagnose.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Diagnose helper: for each failing template + missing check, +show the patterns and the closest substring in the rendered template. +Helps decide whether to fix the Template content or the regex pattern.""" +from __future__ import annotations + +import json +import os +import re +import sys +from typing import Optional + +import psycopg2 +from psycopg2.extras import RealDictCursor + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from compliance.services.doc_checks.runner import _CHECKLIST_MAP # noqa: E402 + +# Re-use the same rendering as the audit script +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from audit_template_completeness import ( # noqa: E402 + TEMPLATE_TO_DOCTYPE, DEMO_PLACEHOLDERS, + render_placeholders, strip_handlebars_blocks, +) + + +def keyword_hits(text: str, keywords: list[str], window: int = 80) -> list[str]: + """Return short context snippets where any keyword appears (case-insensitive).""" + hits = [] + text_lower = text.lower() + for kw in keywords: + for m in re.finditer(re.escape(kw.lower()), text_lower): + start = max(0, m.start() - window // 2) + end = min(len(text), m.end() + window // 2) + snippet = text[start:end].replace("\n", " ").strip() + hits.append(f"… {snippet} …") + if len(hits) >= 3: + return hits + return hits + + +def diagnose_template(tpl_id: str, json_path: str = "/tmp/template_audit_report.json"): + with open(json_path) as f: + audit = json.load(f) + entry = next((a for a in audit if a["template_id"] == tpl_id), None) + if not entry or not entry.get("doc_type"): + print("Not found or no doc_type"); return + print(f"\n=== {entry['template_type']} ({entry['language']}) — {entry['title']} ===") + print(f"doc_type: {entry['doc_type']} | L1: {entry['l1_passed']}/{entry['l1_total']}") + print(f"Missing: {len(entry['l1_missing'])}") + + # Load template content + dsn = os.environ["DATABASE_URL"] + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT content FROM compliance.compliance_legal_templates WHERE id=%s", (tpl_id,)) + row = cur.fetchone() + if not row: + print("Template not in DB"); return + rendered = render_placeholders(strip_handlebars_blocks(row["content"])) + + # Look up checklist + checklist, _label = _CHECKLIST_MAP.get(entry["doc_type"], ([], "")) + by_id = {c["id"]: c for c in checklist} + + for miss in entry["l1_missing"]: + chk = by_id.get(miss["id"]) + print(f"\n ✗ {miss['label']} (id={miss['id']})") + if not chk: + print(" Pattern: (not found in checklist)"); continue + patterns = chk.get("patterns", []) + print(f" Patterns ({len(patterns)}):") + for p in patterns[:5]: + print(f" {p}") + # Heuristic keywords from the label + pattern keywords + keywords = [] + for p in patterns: + # Extract literal words from pattern (rough) + words = re.findall(r"[a-zÀ-ž]{4,}", p, re.IGNORECASE) + keywords.extend(words[:3]) + keywords = list(dict.fromkeys(keywords))[:8] + if keywords: + print(f" Searched keywords: {keywords}") + hits = keyword_hits(rendered, keywords) + if hits: + print(" Closest template snippets:") + for h in hits[:3]: + print(f" • {h[:160]}") + else: + print(" No keyword hits — likely genuinely missing content.") + + +if __name__ == "__main__": + json_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/template_audit_report.json" + if len(sys.argv) > 1 and sys.argv[1] != "all": + diagnose_template(sys.argv[1], json_path) + else: + with open(json_path) as f: + audit = json.load(f) + for a in audit: + if a.get("doc_type") and a.get("l1_missing"): + diagnose_template(a["template_id"], json_path) diff --git a/backend-compliance/scripts/audit_template_completeness.py b/backend-compliance/scripts/audit_template_completeness.py new file mode 100644 index 00000000..a7bfa553 --- /dev/null +++ b/backend-compliance/scripts/audit_template_completeness.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +P39 — Template-Audit: prueft alle Legal-Templates aus der DB gegen +unsere eigenen Pflichtangaben-Checks (doc_checks/*). + +Verwendet check_document_completeness — die gleiche Funktion die auch +externe Sites pruefen wuerde. Reports als Markdown. + +Run inside the bp-compliance-backend container: + docker exec bp-compliance-backend python /app/scripts/audit_template_completeness.py +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from collections import defaultdict +from datetime import datetime, timezone +from typing import Iterable + +import psycopg2 +from psycopg2.extras import RealDictCursor + +# Add compliance package to path if running outside container +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from compliance.services.doc_checks.runner import check_document_completeness # noqa: E402 + +# template_type (DB) -> doc_type (checker) — only those for which we +# have a checklist. Others fall back to LLM-only and skip. +TEMPLATE_TO_DOCTYPE = { + "privacy_policy": "dse", + "data_protection_policy": "dse", + "applicant_dsi": "dse", + "employee_dsi": "dse", + "social_media_dsi": "dse", + "video_conference_dsi": "dse", + "informationspflichten": "dse", + "cookie_policy": "cookie", + "agb": "agb", + "widerruf": "widerruf", + "dpa": "avv", + "dsfa": "dsfa", + "tom_documentation": "tom_annex", + "loeschkonzept": "loeschkonzept", +} + +# Demo replacements for common placeholders so the template has plausible +# concrete values instead of generic {{X}} markers (which would all fail +# regex-based mandatory-field checks). +DEMO_PLACEHOLDERS: dict[str, str] = { + "company_name": "Demo GmbH", + "company_legal_name": "Demo GmbH", + "company_address": "Musterstraße 1, 12345 Berlin", + "company_city": "Berlin", + "company_postal": "12345", + "company_country": "Deutschland", + "company_email": "datenschutz@demo.de", + "company_phone": "+49 30 12345678", + "dpo_name": "Max Mustermann", + "dpo_email": "dsb@demo.de", + "dpo_phone": "+49 30 87654321", + "managing_director": "Erika Mustermann", + "register_court": "Amtsgericht Berlin", + "register_number": "HRB 123456", + "vat_id": "DE123456789", + "supervisory_authority": "Berliner Beauftragte für Datenschutz", + "supervisory_address": "Friedrichstr. 219, 10969 Berlin", + "retention_period": "10 Jahre nach Vertragsende", + "third_country": "USA", + "transfer_mechanism": "EU-Standardvertragsklauseln", + "date": "2026-05-20", + "version": "1.0", +} + + +def render_placeholders(content: str) -> str: + """Replace {{key}} placeholders with demo values. Unknown placeholders + are stripped to empty string so the regex checks see plausible text.""" + def repl(m: re.Match) -> str: + key = m.group(1).strip().lower() + # Hyphens / underscores normalised + key_norm = key.replace("-", "_") + if key_norm in DEMO_PLACEHOLDERS: + return DEMO_PLACEHOLDERS[key_norm] + return f"[{key}]" # leave hint for context but don't break sentences + # Match {{anything}} including dots and brackets used in conditional blocks + return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", repl, content) + + +def strip_handlebars_blocks(content: str) -> str: + """Drop {{#IF X}}...{{/IF}} markers but keep inner content (audit + only cares whether mandatory text appears anywhere, not which branch + is active).""" + # Remove block markers but keep enclosed content + content = re.sub(r"\{\{#IF[^}]*\}\}", "", content) + content = re.sub(r"\{\{/IF\}\}", "", content) + content = re.sub(r"\{\{#UNLESS[^}]*\}\}", "", content) + content = re.sub(r"\{\{/UNLESS\}\}", "", content) + content = re.sub(r"\{\{else\}\}", "", content) + return content + + +def fetch_templates(conn) -> list[dict]: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT id, document_type, language, title, content + FROM compliance.compliance_legal_templates + WHERE status = 'published' + ORDER BY document_type, language + """) + return list(cur.fetchall()) + + +def audit_template(tpl: dict) -> dict: + """Audit a single template — returns dict with findings + summary.""" + doc_type = TEMPLATE_TO_DOCTYPE.get(tpl["document_type"]) + if not doc_type: + return { + "template_id": tpl["id"], + "template_type": tpl["document_type"], + "language": tpl["language"], + "title": tpl["title"], + "doc_type": None, + "skipped_reason": "no_checklist_mapping", + "l1_total": 0, "l1_passed": 0, "l1_missing": [], + } + raw = tpl["content"] or "" + rendered = strip_handlebars_blocks(raw) + rendered = render_placeholders(rendered) + findings = check_document_completeness( + text=rendered, + doc_type=doc_type, + doc_title=tpl["title"] or tpl["document_type"], + doc_url=f"template://{tpl['id']}", + ) + # findings is a list of dicts; the first finding usually has 'all_checks' + all_checks: list[dict] = [] + for f in findings: + if "all_checks" in f and f["all_checks"]: + all_checks = f["all_checks"] + break + l1_checks = [c for c in all_checks if c.get("level", 1) == 1] + l1_missing = [c for c in l1_checks if not c.get("passed") and not c.get("skipped")] + return { + "template_id": tpl["id"], + "template_type": tpl["document_type"], + "language": tpl["language"], + "title": tpl["title"], + "doc_type": doc_type, + "l1_total": len(l1_checks), + "l1_passed": sum(1 for c in l1_checks if c.get("passed") and not c.get("skipped")), + "l1_missing": [ + {"id": c.get("id"), "label": c.get("label"), "hint": c.get("hint", "")[:200]} + for c in l1_missing + ], + "word_count": len(rendered.split()), + } + + +def render_markdown_report(results: Iterable[dict]) -> str: + results = list(results) + audited = [r for r in results if r.get("doc_type")] + skipped = [r for r in results if not r.get("doc_type")] + by_type = defaultdict(list) + for r in audited: + by_type[r["template_type"]].append(r) + + lines = [] + lines.append(f"# Template-Audit (P39)") + lines.append("") + lines.append(f"**Datum:** {datetime.now(timezone.utc).isoformat()}") + lines.append(f"**Methode:** check_document_completeness gegen jede Vorlage") + lines.append("") + lines.append(f"- Templates gesamt: {len(results)}") + lines.append(f"- Auditierbar (mit Checklist-Mapping): {len(audited)}") + lines.append(f"- Uebersprungen (kein doc_type-Mapping): {len(skipped)}") + lines.append("") + + # Summary table by template_type + lines.append("## Zusammenfassung pro Template-Typ") + lines.append("") + lines.append("| Template-Type | Sprache | L1-Score | Fehlende Pflichtangaben |") + lines.append("|---|---|---|---|") + for tpl_type in sorted(by_type): + for r in by_type[tpl_type]: + ratio = f"{r['l1_passed']}/{r['l1_total']}" if r["l1_total"] else "—" + missing_count = len(r["l1_missing"]) + lines.append( + f"| `{tpl_type}` | {r['language']} | {ratio} | " + f"{missing_count} fehlt" + ("e" if missing_count != 1 else "") + + (f": {', '.join(c['label'] for c in r['l1_missing'])}" if r['l1_missing'] else "") + + " |" + ) + lines.append("") + + # Per-template details — only those with failures + failed = [r for r in audited if r["l1_missing"]] + lines.append(f"## Details: {len(failed)} Templates mit fehlenden Pflichtangaben") + lines.append("") + for r in failed: + lines.append(f"### {r['template_type']} ({r['language']}) — {r['title']}") + lines.append("") + lines.append(f"- Doc-Type: `{r['doc_type']}`") + lines.append(f"- Wortzahl: {r['word_count']}") + lines.append(f"- L1-Score: {r['l1_passed']}/{r['l1_total']}") + lines.append(f"- Fehlend ({len(r['l1_missing'])}):") + for c in r["l1_missing"]: + lines.append(f" - **{c['label']}** (`{c['id']}`)") + if c.get("hint"): + lines.append(f" - Hinweis: {c['hint']}") + lines.append("") + + # Templates without checklist + if skipped: + lines.append("## Templates ohne automatische Pflichtangaben-Pruefung") + lines.append("") + lines.append("Diese Templates haben keinen Doc-Check-Mapping — sie werden " + "nicht automatisch gepruft. Bei Bedarf manuell oder via LLM " + "zu pruefen.") + lines.append("") + for r in sorted(skipped, key=lambda x: x["template_type"]): + lines.append(f"- `{r['template_type']}` ({r['language']}): {r['title']}") + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + dsn = os.environ.get("DATABASE_URL") or os.environ.get("COMPLIANCE_DATABASE_URL") + if not dsn: + print("ERROR: DATABASE_URL not set", file=sys.stderr) + return 1 + conn = psycopg2.connect(dsn) + templates = fetch_templates(conn) + print(f"Auditing {len(templates)} templates...", file=sys.stderr) + + results = [] + for tpl in templates: + try: + results.append(audit_template(tpl)) + except Exception as e: + print(f" ! {tpl['document_type']}/{tpl['language']}: {e}", file=sys.stderr) + results.append({ + "template_id": tpl["id"], + "template_type": tpl["document_type"], + "language": tpl["language"], + "title": tpl["title"], + "doc_type": None, + "skipped_reason": f"error: {e}", + "l1_total": 0, "l1_passed": 0, "l1_missing": [], + }) + + report_md = render_markdown_report(results) + out_path = os.environ.get( + "AUDIT_OUTPUT", + "/tmp/template_audit_report.md", + ) + with open(out_path, "w") as f: + f.write(report_md) + # Also dump raw JSON for further analysis + json_path = out_path.replace(".md", ".json") + with open(json_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f"Report: {out_path}", file=sys.stderr) + print(f"Raw JSON: {json_path}", file=sys.stderr) + # Short summary to stdout + audited = [r for r in results if r.get("doc_type")] + failed = [r for r in audited if r["l1_missing"]] + print(f"\n== Audit Summary ==") + print(f"Total templates: {len(results)}") + print(f"Auditable: {len(audited)}") + print(f"With failures: {len(failed)}") + print(f"Skipped (no mapping): {len(results) - len(audited)}") + # P42: CI mode — exit non-zero when any auditable template fails L1 + if "--strict" in sys.argv and failed: + print(f"\nFAIL: {len(failed)} template(s) missing mandatory fields:", + file=sys.stderr) + for r in failed: + missing = ", ".join(c["label"] for c in r["l1_missing"]) + print(f" - {r['template_type']} [{r['language']}]: {missing}", + file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend-compliance/scripts/fix_template_content.py b/backend-compliance/scripts/fix_template_content.py new file mode 100644 index 00000000..38770377 --- /dev/null +++ b/backend-compliance/scripts/fix_template_content.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +P39 Phase B — Fix actual content gaps in legal templates. + +For each template with a genuine content gap (identified by P39 audit), +insert the missing mandatory section. Targeted edits — does NOT +overwrite the full template content. + +Templates fixed: + - data_protection_policy: add "Verantwortlicher" section (Art. 13(1)(a)) + - applicant_dsi: add "Drittlandtransfer" section (Art. 13(1)(f)) + - employee_dsi: add "Drittlandtransfer" section (Art. 13(1)(f)) + - cookie_policy: add concrete cookie table example + - dsfa: add LfDI / Aufsichtsbehoerden-Referenz + - widerruf: add §312k BGB Online-Kuendigungsbutton clause + +Run inside container: + docker exec bp-compliance-backend python /app/scripts/fix_template_content.py + (dry-run by default; pass --apply to UPDATE the DB) +""" + +from __future__ import annotations + +import os +import sys + +import psycopg2 +from psycopg2.extras import RealDictCursor + + +# Sentinels: each fix has (a) where to insert, (b) what to insert, +# (c) a check string to verify the insertion already happened (idempotent). + +FIXES = [ + { + "document_type": "data_protection_policy", + "language": "de", + "already_done_marker": "## 1. Verantwortlicher", + "anchor": None, # Insert at top (after first heading) + "insert_block": """## 1. Verantwortlicher + +Verantwortlich fuer die in dieser Richtlinie beschriebene Verarbeitung personenbezogener Daten im Sinne der DSGVO ist: + +**{{company_legal_name}}** +{{company_address}} +{{company_postal}} {{company_city}}, {{company_country}} + +E-Mail: {{company_email}} +Telefon: {{company_phone}} + +Datenschutzbeauftragte/r: {{dpo_name}} ({{dpo_email}}) + +""", + }, + { + "document_type": "applicant_dsi", + "language": "de", + "already_done_marker": "## 7. Drittlandtransfer", + "anchor": "## 7.", # generic; we insert before whatever 7 is + "insert_block": """## 7. Drittlandtransfer (Art. 13(1)(f) DSGVO) + +Eine Uebermittlung Ihrer Bewerberdaten in Laender ausserhalb der Europaeischen Union oder des Europaeischen Wirtschaftsraums (Drittland) findet **nicht** statt. Saemtliche Verarbeitung erfolgt ausschliesslich auf Servern innerhalb der EU. + +Sollten in Ausnahmefaellen Drittlandtransfers erforderlich werden (z.B. Konzern-Verbund mit US-Schwestergesellschaft), erfolgen diese ausschliesslich auf Basis von EU-Standardvertragsklauseln (Art. 46(2)(c) DSGVO) oder eines Angemessenheitsbeschlusses der EU-Kommission (Art. 45 DSGVO). + +""", + }, + { + "document_type": "employee_dsi", + "language": "de", + "already_done_marker": "## 7. Drittlandtransfer", + "anchor": "## 7.", + "insert_block": """## 7. Drittlandtransfer (Art. 13(1)(f) DSGVO) + +Eine Uebermittlung Ihrer Beschaeftigtendaten in Laender ausserhalb der Europaeischen Union oder des Europaeischen Wirtschaftsraums (Drittland) findet grundsaetzlich **nicht** statt. Eine Ausnahme bilden Cloud-Dienste, die ggf. auf US-Server zugreifen — in diesem Fall erfolgt die Uebermittlung auf Basis von EU-Standardvertragsklauseln (Art. 46(2)(c) DSGVO) oder unter dem EU-US Data Privacy Framework (Angemessenheitsbeschluss vom 10.07.2023, Art. 45 DSGVO). + +Empfaengerland und Schutzmechanismus pro genutztem Dienst: siehe Verarbeitungsverzeichnis (VVT). + +""", + }, + { + "document_type": "cookie_policy", + "language": "de", + "already_done_marker": "### 4.1 Konkrete Cookie-Tabelle", + "anchor": None, # append before the final heading or at end + "insert_block": """### 4.1 Konkrete Cookie-Tabelle (Beispiel) + +| Name | Anbieter | Zweck | Speicherdauer | Typ | +|---|---|---|---|---| +| `__session` | {{company_legal_name}} | Sitzungs-Authentifizierung | Sitzungsende | First-Party, technisch notwendig | +| `cookie_consent` | {{company_legal_name}} | Speicherung der Cookie-Einwilligung | 12 Monate | First-Party, technisch notwendig | +| `_ga` | Google Ireland Ltd. | Webanalyse (Google Analytics) | 2 Jahre | Third-Party, Statistik — Einwilligung erforderlich | +| `_fbp` | Meta Platforms Ireland Ltd. | Marketing / Conversion-Tracking | 90 Tage | Third-Party, Marketing — Einwilligung erforderlich | + +> Hinweis: Die obenstehende Tabelle ist beispielhaft. Die tatsaechlich von Ihrer Website gesetzten Cookies pflegen Sie im Backend Ihres Consent-Tools (z.B. Cookiebot, Usercentrics, Borlabs). Die DSK-Orientierungshilfe Telemedien 2024 fordert je Cookie: Name, Anbieter, Zweck, Speicherdauer, Typ (First-/Third-Party). + +""", + }, + { + "document_type": "dsfa", + "language": "de", + "already_done_marker": "### 0.2 Beruecksichtigung Landesaufsichtsbehoerden", + "anchor": None, + "insert_block": """### 0.2 Beruecksichtigung Landesaufsichtsbehoerden (LfDI) und DSK-Liste + +Diese DSFA beruecksichtigt: + +- **DSK-Positivliste** nach Art. 35(4) DSGVO: Die Datenschutzkonferenz (DSK) hat eine Liste von Verarbeitungen veroeffentlicht, die zwingend eine DSFA erfordern. Pruefen Sie, ob Ihre Verarbeitung dort gelistet ist. +- **Landesbeauftragte fuer Datenschutz (LfDI)**: Jedes Bundesland (BfDI, BlnBDI, LfDI BW, LfDI BY, etc.) veroeffentlicht eigene Orientierungshilfen und Branchen-Stellungnahmen. Zustaendige Behoerde: {{supervisory_authority}}. +- **EDPB Guidelines** (insbesondere WP248 — Kriterien fuer DSFA-Erforderlichkeit, Art. 29-Datenschutzgruppe). +- **Branchenspezifische Aufsichtsempfehlungen** (z.B. Telemedien: DSK-OH 2024, Gesundheit: BfDI-Empfehlungen). + +""", + }, + { + "document_type": "widerruf", + "language": "de", + "already_done_marker": "## §312k BGB", + "anchor": None, + "insert_block": """## §312k BGB — Online-Kuendigungsbutton (bei Dauerschuldverhaeltnissen) + +Bietet der Unternehmer Vertraege ueber **Dauerschuldverhaeltnisse** (Abonnements, Mitgliedschaften, SaaS-Subscriptions) auf seiner Website an, muss er nach §312k BGB einen Kuendigungsbutton bereitstellen. + +**Anforderungen** (BGH-Rechtsprechung 2023): + +- Der Button muss deutlich beschriftet sein mit "Vertraege hier kuendigen" oder gleichwertig. +- Direkt nach Klick muss eine Bestaetigungsseite folgen mit Angaben zu Vertragsart, Vertragspartnern und Kuendigungstermin. +- Nach Bestaetigung muss eine Bestaetigung der Kuendigung per E-Mail oder dauerhaft auf einem Datentraeger zur Verfuegung gestellt werden. + +**Verstoss**: Eine Kuendigung kann auch ohne den Button per E-Mail/Brief jederzeit erfolgen — fehlt der Button, kann der Vertrag zudem von der zustaendigen Verbraucherzentrale abgemahnt werden (§312k Abs. 6 BGB). + +**Ausnahme**: §312k gilt nur fuer Verbraucherkunden (B2C). Bei reinen B2B-Vertraegen besteht keine Pflicht. + +""", + }, +] + + +def apply_fix(content: str, fix: dict) -> tuple[str, str]: + """Returns (new_content, status). Status: 'unchanged'/'inserted'/'already-fixed'.""" + if fix["already_done_marker"] in content: + return content, "already-fixed" + anchor = fix["anchor"] + if anchor and anchor in content: + # Insert BEFORE the anchor + new_content = content.replace(anchor, fix["insert_block"] + anchor, 1) + else: + # Append at end + new_content = content.rstrip() + "\n\n" + fix["insert_block"] + return new_content, "inserted" + + +def main(apply: bool): + dsn = os.environ.get("DATABASE_URL") or os.environ.get("COMPLIANCE_DATABASE_URL") + if not dsn: + print("ERROR: DATABASE_URL not set", file=sys.stderr) + return 1 + conn = psycopg2.connect(dsn) + cur = conn.cursor(cursor_factory=RealDictCursor) + summary = [] + for fix in FIXES: + cur.execute( + "SELECT id, content FROM compliance.compliance_legal_templates " + "WHERE document_type=%s AND language=%s AND status='published'", + (fix["document_type"], fix["language"]), + ) + rows = cur.fetchall() + if not rows: + summary.append((fix["document_type"], fix["language"], "not-found", 0)) + continue + for row in rows: + new_content, status = apply_fix(row["content"], fix) + if status == "inserted" and apply: + cur.execute( + "UPDATE compliance.compliance_legal_templates " + "SET content=%s, updated_at=now() WHERE id=%s", + (new_content, row["id"]), + ) + summary.append((fix["document_type"], fix["language"], status, + len(new_content) - len(row["content"]))) + if apply: + conn.commit() + print(f"\n== Template Content Fixes ({'APPLIED' if apply else 'DRY-RUN'}) ==") + for doc_type, lang, status, delta in summary: + marker = "✓" if status == "inserted" else ("·" if status == "already-fixed" else "✗") + print(f" {marker} {doc_type:30s} [{lang}] {status:14s} (+{delta} chars)") + return 0 + + +if __name__ == "__main__": + apply = "--apply" in sys.argv + sys.exit(main(apply)) diff --git a/backend-compliance/scripts/seed_cookie_library.py b/backend-compliance/scripts/seed_cookie_library.py new file mode 100644 index 00000000..85954d7f --- /dev/null +++ b/backend-compliance/scripts/seed_cookie_library.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""P59 Phase 2 — Seed compliance.cookie_library from Open Cookie Database (CC0). + +Open Cookie Database: jkwakman/Open-Cookie-Database (CC0-1.0 Public Domain). +~700 categorised cookies maintained by Cybot/Cookiebot community.""" +from __future__ import annotations + +import csv +import io +import os +import sys +import urllib.request + +import psycopg2 + +OCD_URL = ( + "https://raw.githubusercontent.com/jkwakman/Open-Cookie-Database/master/" + "open-cookie-database.csv" +) + +CATEGORY_MAP = { + "strictly necessary": "essential", + "functional": "functional", + "performance": "statistics", + "analytics": "statistics", + "targeting": "marketing", + "marketing": "marketing", + "advertisement": "marketing", + "social media": "social_media", + "unclassified": "unknown", +} + + +def parse_max_age(retention: str) -> int | None: + """Approximate seconds from retention strings like '2 years' / '30 days'.""" + if not retention: + return None + r = retention.lower().strip() + if "session" in r: + return 0 + import re + m = re.search(r"(\d+)\s*(jahr|year|day|tag|month|monat|hour|stund|minute)", r) + if not m: + return None + n = int(m.group(1)) + unit = m.group(2) + multipliers = { + "jahr": 31536000, "year": 31536000, + "month": 2592000, "monat": 2592000, + "day": 86400, "tag": 86400, + "hour": 3600, "stund": 3600, + "minute": 60, + } + return n * multipliers.get(unit, 1) + + +def main() -> int: + dsn = os.environ.get("DATABASE_URL") + if not dsn: + print("DATABASE_URL missing", file=sys.stderr); return 1 + print(f"Fetching {OCD_URL} ...", file=sys.stderr) + try: + with urllib.request.urlopen(OCD_URL, timeout=30) as r: + body = r.read().decode("utf-8", errors="replace") + except Exception as e: + print(f"Fetch failed: {e}", file=sys.stderr); return 2 + reader = csv.DictReader(io.StringIO(body)) + rows = list(reader) + print(f"Parsed {len(rows)} rows", file=sys.stderr) + + conn = psycopg2.connect(dsn) + cur = conn.cursor() + inserted = 0 + skipped = 0 + for r in rows: + name = (r.get("Cookie / Data Key name") or "").strip() + domain = (r.get("Domain") or "").strip() + if not name: + skipped += 1 + continue + category_raw = (r.get("Category") or "").strip().lower() + actual_category = CATEGORY_MAP.get(category_raw, "unknown") + vendor = (r.get("Platform") or r.get("Data Controller") or "Unknown").strip() + purpose = (r.get("Description") or "").strip()[:1000] + privacy_url = (r.get("User Privacy & GDPR Rights Portals") or "").strip() + max_age = parse_max_age(r.get("Retention period") or "") + # Wildcard match flag → domain_pattern + domain_pattern = domain or "*" + cur.execute( + """ + INSERT INTO compliance.cookie_library + (cookie_name, domain_pattern, vendor_name, + vendor_privacy_url, actual_category, purpose_en, + typical_max_age_seconds, source_name, source_url, + source_license, confidence) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """, + (name, domain_pattern, vendor[:200], privacy_url or None, + actual_category, purpose or None, max_age, + "Open Cookie Database", OCD_URL, "CC0-1.0", 0.75), + ) + inserted += cur.rowcount + conn.commit() + print(f"\nInserted {inserted}, skipped {skipped}") + cur.execute("SELECT actual_category, COUNT(*) " + "FROM compliance.cookie_library GROUP BY actual_category " + "ORDER BY 2 DESC") + for row in cur.fetchall(): + print(f" {row[0]:15s}: {row[1]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/consent-tester/main.py b/consent-tester/main.py index af22d641..ab725e17 100644 --- a/consent-tester/main.py +++ b/consent-tester/main.py @@ -50,6 +50,9 @@ class ScanResponse(BaseModel): completeness_pct: int = 0 correctness_pct: int = 0 tcf_vendors: list = [] # Resolved TCF vendor list from GVL + cmp_payloads: list[dict] = [] # P48: raw CMP JSON-payloads (Usercentrics/OneTrust/...) captured during scan + vendor_details: list[dict] = [] # P50: per-vendor detail-modal-extracts (Beschreibung/Cookies/Opt-Out/Privacy) + cookies_detailed: list[dict] = [] # P59b: full cookie details for behavior-validation (name,value,domain,expires,phase,declared_category) @app.get("/health") @@ -127,6 +130,9 @@ async def scan_consent(req: ScanRequest): "provider_details_visible": getattr(ct, "provider_details_visible", False), "cookies_set": ct.cookies_set, } for ct in result.category_tests] if result.category_tests else [], + cmp_payloads=result.cmp_payloads, # P48 + vendor_details=result.vendor_details, # P50 + cookies_detailed=result.cookies_detailed, # P59b ) diff --git a/consent-tester/services/banner_advanced_checks.py b/consent-tester/services/banner_advanced_checks.py index d2856e89..38edd5cd 100644 --- a/consent-tester/services/banner_advanced_checks.py +++ b/consent-tester/services/banner_advanced_checks.py @@ -383,13 +383,23 @@ async def run_advanced_checks(page, banner_text: str) -> list[Violation]: ] for pattern, label in stirring_patterns: if pattern in banner_lower: + # P67: konkrete Erklaerung statt nur Fachbegriff. Marketing/GF + # versteht "Stirring" nicht — aber "Verlust-Framing" versteht + # jeder, und der Vergleich (alt vs neutral) macht es greifbar. violations.append(Violation( service="Cookie-Banner", severity="LOW", - text=f"Emotionale Sprache im Banner: {label}. " - f"Solche Formulierungen koennen als 'Stirring' (emotionale Manipulation) " - f"gewertet werden und die Freiwilligkeit der Einwilligung beeintraechtigen.", - legal_ref="EDPB Guidelines 3/2022 (Deceptive Design: Stirring), Art. 7(4) DSGVO", + text=f"Verlust-Framing im Banner-Text: {label}. " + f"Diese Formulierung suggeriert, dass das Nicht-Zustimmen " + f"eine schlechtere ('nicht-optimale') Nutzung bedeutet — " + f"selbst wenn die Website ohne Cookies technisch genauso " + f"funktioniert. Die EDPB (3/2022) nennt das 'Stirring': " + f"emotionale Hebel statt informierter Entscheidung. " + f"Empfehlung: neutrale Sprache ('Nutzung unserer Website') " + f"statt qualitativer Bewertung ('optimal', 'voll', " + f"'bestmoeglich').", + legal_ref="EDPB Guidelines 3/2022 (Deceptive Design: Stirring), " + "Art. 7(4) DSGVO (Freiwilligkeit)", )) break # One finding is enough diff --git a/consent-tester/services/banner_detector.py b/consent-tester/services/banner_detector.py index 4369f825..3d84128f 100644 --- a/consent-tester/services/banner_detector.py +++ b/consent-tester/services/banner_detector.py @@ -17,6 +17,32 @@ class BannerInfo: reject_selector: str +# P22: Web-Component-CMPs (Banner ist im Shadow-DOM eines custom-element). +# Standard-Selektoren greifen nicht — Detection ueber Tag-Name. +WEB_COMPONENT_CMP_TAGS = [ + { + "tag": "cmm-cookie-banner", + "provider": "Mercedes (cmm-cookie-banner)", + # Mercedes-Banner-Buttons im Shadow: "Alle akzeptieren" / + # "Nur technisch notwendige" / "Einstellungen" + "accept_text": "Alle akzeptieren", + "reject_text": "Nur technisch notwendige", + }, + { + "tag": "cookie-consent-banner", + "provider": "Generic Web Component (cookie-consent-banner)", + "accept_text": "akzeptieren|accept|zustimmen", + "reject_text": "ablehnen|reject|notwendig", + }, + { + "tag": "consent-banner", + "provider": "Generic Web Component (consent-banner)", + "accept_text": "akzeptieren|accept", + "reject_text": "ablehnen|reject", + }, +] + + # CMP-specific selectors (ordered by market share) CMP_SELECTORS = [ { @@ -409,6 +435,23 @@ async def _detect_generic_attr(page: Page) -> BannerInfo | None: async def detect_banner(page: Page) -> BannerInfo: """Detect which CMP is used and return button selectors.""" + # P22: Web-Component-CMPs (Mercedes etc.) — direkter Tag-Check. + # Shadow-DOM-Buttons werden via shadow-click:-Selektor angesprochen. + for wc in WEB_COMPONENT_CMP_TAGS: + try: + count = await page.evaluate( + "(tag) => document.querySelectorAll(tag).length", + wc["tag"], + ) + if count > 0: + return BannerInfo( + detected=True, provider=wc["provider"], + accept_selector=f"shadow-click:{wc['accept_text']}", + reject_selector=f"shadow-click:{wc['reject_text']}", + ) + except Exception: + continue + # 1. Try CMP-specific selectors for cmp in CMP_SELECTORS: try: diff --git a/consent-tester/services/banner_dom_walkers.py b/consent-tester/services/banner_dom_walkers.py new file mode 100644 index 00000000..e0c1400f --- /dev/null +++ b/consent-tester/services/banner_dom_walkers.py @@ -0,0 +1,108 @@ +""" +Browser-side DOM walkers for Web-Component CMPs and OEM design-systems. + +Centralizes the JavaScript snippets used by banner_text_checker.py so the +checker file stays under the 500-LOC cap. Each function returns a JS string +that Playwright passes to `page.evaluate()`. + +Two walkers: + * SHADOW_BANNER_WALKER_JS — pierces shadow DOM (Mercedes cmm-cookie-banner, + BMW cookie-consent-banner, etc.) and extracts banner text + label-based + legal links (P63 — recognizes wb7-link/role=link/button, not just + , since OEM design-systems wrap navigation). + * FOOTER_LABELS_WALKER_JS — collects unique footer link labels from any + candidate footer root (footer, [role=contentinfo], wb7-footer, ...) with + a bottom-25%-of-viewport fallback (P64). +""" + +from __future__ import annotations + + +SHADOW_BANNER_WALKER_JS = """() => { + const LEGAL_KW = { + impressum: ['impressum','imprint','legal notice','mentions legales','colophon'], + dse: ['datenschutz','privacy','dsgvo','data protection','politique de confidentialite'], + }; + function isLegalLabel(txt) { + const t = (txt||'').toLowerCase(); + if (!t || t.length > 60) return null; + for (const k of LEGAL_KW.impressum) if (t.includes(k)) return 'impressum'; + for (const k of LEGAL_KW.dse) if (t.includes(k)) return 'dse'; + return null; + } + function walk(root, acc) { + if (!root) return; + const all = root.querySelectorAll ? root.querySelectorAll('*') : []; + for (const el of all) { + if (el.shadowRoot) walk(el.shadowRoot, acc); + } + const tags = ['cmm-cookie-banner', 'cookie-consent-banner', + 'consent-banner', 'cookie-banner', 'cmp-banner', + 'ot-banner', 'usercentrics-banner']; + for (const tag of tags) { + const els = root.querySelectorAll ? root.querySelectorAll(tag) : []; + for (const el of els) { + if (el.shadowRoot) { + const txt = (el.shadowRoot.textContent || '').trim(); + if (txt) acc.text += ' ' + txt; + const links = el.shadowRoot.querySelectorAll('a[href]'); + for (const a of links) { + acc.links.push({ + href: (a.getAttribute('href') || '').toLowerCase(), + text: (a.textContent || '').trim().toLowerCase(), + }); + } + const cands = el.shadowRoot.querySelectorAll( + 'wb7-link, wb7-button, [role="link"], button, span, a' + ); + for (const c of cands) { + const label = (c.textContent || '').trim(); + const which = isLegalLabel(label); + if (which) { + const href = (c.getAttribute('href') || + c.getAttribute('data-href') || + c.getAttribute('data-uri') || '').toLowerCase(); + acc.links.push({ + href: href || ('#shadow-' + which), + text: label.toLowerCase(), + }); + } + } + } + } + } + } + const acc = { text: '', links: [] }; + walk(document, acc); + return acc; +}""" + + +FOOTER_LABELS_WALKER_JS = """() => { + const out = new Set(); + const roots = [ + ...document.querySelectorAll( + 'footer, [role="contentinfo"], ' + + 'wb7-footer, wb-footer, b-footer, cmm-footer, ' + + '[class*="footer" i], [id*="footer" i]' + ) + ]; + if (roots.length === 0) { + const viewH = window.innerHeight; + for (const el of document.querySelectorAll('a, button, [role="link"], wb7-link')) { + const r = el.getBoundingClientRect(); + if (r.top > viewH * 0.75) roots.push(el.parentElement); + } + } + for (const root of roots) { + if (!root) continue; + const cands = root.querySelectorAll('a, button, [role="link"], wb7-link, wb7-button'); + let n = 0; + for (const c of cands) { + if (n++ > 80) break; + const t = (c.textContent || '').trim().toLowerCase(); + if (t && t.length < 60) out.add(t); + } + } + return [...out]; +}""" diff --git a/consent-tester/services/banner_text_checker.py b/consent-tester/services/banner_text_checker.py index 77d55101..433fb268 100644 --- a/consent-tester/services/banner_text_checker.py +++ b/consent-tester/services/banner_text_checker.py @@ -19,6 +19,10 @@ import logging from services.script_analyzer import Violation from services.banner_advanced_checks import run_advanced_checks +from services.banner_dom_walkers import ( + SHADOW_BANNER_WALKER_JS, + FOOTER_LABELS_WALKER_JS, +) logger = logging.getLogger(__name__) @@ -62,6 +66,21 @@ async def check_banner_text(page) -> dict: except Exception: continue + # P28a + P63: Shadow-DOM Web Component CMPs (Mercedes cmm-cookie-banner, + # BMW cookie-consent-banner). Walker pierces shadow tree + extracts + # label-based legal links (wb7-link/button/role=link). See + # banner_dom_walkers.SHADOW_BANNER_WALKER_JS. + if not banner_text or not banner_links: + try: + shadow_data = await page.evaluate(SHADOW_BANNER_WALKER_JS) + if shadow_data and isinstance(shadow_data, dict): + if shadow_data.get("text"): + banner_text = (banner_text + " " + shadow_data["text"]).strip() + if shadow_data.get("links"): + banner_links.extend(shadow_data["links"]) + except Exception: + pass + if not banner_text: return {"violations": violations, "has_impressum": False, "has_dse": False} @@ -134,17 +153,38 @@ async def check_banner_text(page) -> dict: )) break - # Check 4: Reject button visible (no hidden reject) - reject_texts = ["ablehnen", "reject", "nur notwendige", "alle ablehnen", "decline"] - has_visible_reject = any(t in banner_lower for t in reject_texts) - if not has_visible_reject: + # P28b Check 4: Reject mechanism present + explicit-labeled? + # HIGH = no reject mechanism at all + # MEDIUM = reject available but not labeled "Ablehnen"/"Reject" + # (e.g. only "Nur technisch Notwendige" — semantically + # a reject but EDPB 5/2020 + DSK-OH 2024 prefer explicit + # labeling so users recognize it as the reject option) + explicit_reject_texts = ["ablehnen", "reject", "alle ablehnen", + "decline", "alles ablehnen"] + implicit_reject_texts = ["nur notwendige", "nur technisch", "nur essenzielle", + "nur essentielle", "notwendige akzeptieren", + "essential only", "only necessary", + "nur erforderliche"] + has_explicit_reject = any(t in banner_lower for t in explicit_reject_texts) + has_implicit_reject = any(t in banner_lower for t in implicit_reject_texts) + if not has_explicit_reject and not has_implicit_reject: violations.append(Violation( service="Cookie-Banner", severity="HIGH", - text="Kein sichtbarer 'Ablehnen'-Button im Banner erkannt. " + text="Kein 'Ablehnen'-Mechanismus im Banner erkannt. " "Die Ablehnung muss ebenso einfach sein wie die Zustimmung.", legal_ref="§25 Abs. 1 TDDDG, EDPB Guidelines 05/2020 (Consent)", )) + elif not has_explicit_reject and has_implicit_reject: + violations.append(Violation( + service="Cookie-Banner", + severity="MEDIUM", + text="Reject-Moeglichkeit vorhanden ('Nur technisch Notwendige' o.ae.), " + "aber nicht als 'Ablehnen' beschriftet. Nutzer erkennen 'Ablehnen' " + "schneller als sprachlich umschriebene Varianten. " + "Empfehlung: zusaetzlich 'Ablehnen' als Button-Label.", + legal_ref="EDPB 5/2020 (Consent) + DSK-OH 2024 (Telemedien)", + )) # Check 5: Pre-ticked checkboxes (EuGH Planet49) try: @@ -210,7 +250,8 @@ async def check_banner_text(page) -> dict: accept_btn = None reject_btn = None accept_kw = ["akzeptieren", "accept", "zustimmen", "agree", "einverstanden", "ok"] - reject_kw = ["ablehnen", "reject", "notwendige", "decline", "nein"] + reject_kw = ["ablehnen", "reject", "notwendige", "decline", "nein", + "technisch", "essenzielle", "essential", "erforderliche"] for btn in button_info: text_lower = btn["text"].lower() @@ -245,44 +286,90 @@ async def check_banner_text(page) -> dict: # Check 7: Cookie Wall — does rejecting block the site? # (This is checked in Phase B — if after reject the page is not navigable) - # Check 8: Re-access to settings (Art. 7(3) — revocation as easy as consent) + # P29 Check 8: Re-access to cookie settings (Art. 7(3) DSGVO). + # Three quality tiers: + # OK = persistent floating cookie icon OR explicit-labeled + # footer link ("Cookie-Einstellungen", "Cookie-Richtlinie", + # "Cookies verwalten", etc.) + # MEDIUM = re-access only via ambiguous label (e.g. "Einstellungen" + # alone — could mean theme/language) OR only via + # cookies.html doc link (not a settings dialog) + # HIGH = no re-access mechanism found at all try: - settings_accessible = False - settings_selectors = [ - '[class*="cookie-settings"]', '[class*="privacy-settings"]', - 'a[href*="cookie"]', 'a[href*="datenschutz-einstellungen"]', - '[class*="consent-settings"]', '#ot-sdk-btn', - '.cky-btn-revisit', '#CybotCookiebotDialogBodyButtonDetails', - '[data-testid="uc-footer-link"]', + has_floating_icon = False + floating_selectors = [ + ".cky-btn-revisit", "#ot-sdk-btn", "#ot-sdk-btn-floating", + "[class*='ot-floating']", "[class*='cookie-floating']", + "[id*='cookiebot-renew']", "[class*='cmp-floating']", + "[id*='cmplz-cookiebanner-status']", ".uc-cookie-settings-trigger", + "[class*='consent-floating']", "[data-testid*='cookie-revisit']", ] - for sel in settings_selectors: + for sel in floating_selectors: try: if await page.locator(sel).count() > 0: - settings_accessible = True + has_floating_icon = True break except Exception: continue - # Also check footer for cookie settings link - if not settings_accessible: - footer_text = "" - try: - footer = page.locator("footer").first - if await footer.count() > 0: - footer_text = (await footer.text_content() or "").lower() - except Exception: - pass - if any(kw in footer_text for kw in ["cookie-einstellungen", "cookie settings", - "datenschutz-einstellungen", "privacy settings"]): - settings_accessible = True + # Footer label inspection — distinguish explicit vs ambiguous + # P64: OEM design-systems (Mercedes wb7-footer, BMW b-footer) don't + # use