feat(consent+report): P56-P67 Mercedes-Audit-Cycle (Anti-Audit, Phase G Vendors, Cookie-Behavior-Validator + 5 Mail-Polish-Items) [migration-approved]
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / nodejs-build (push) Successful in 2m19s
CI / test-go (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s

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 <a href>)
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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-21 06:28:25 +02:00
parent badb356740
commit 57c0f940a2
38 changed files with 3656 additions and 116 deletions
@@ -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 "<empty>", 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",
@@ -202,5 +202,34 @@ def build_banner_deep_html(banner_result: dict | None) -> str:
)
parts.append('</ul>')
# 5) P59b: Cookie-Behavior-Findings (deklariert vs. tatsaechlich)
cb_findings = banner_result.get("cookie_behavior_findings") or []
if cb_findings:
parts.append(
'<div style="margin:14px 0 4px;padding:8px 12px;'
'background:#fef9e7;border-left:3px solid #d97706;border-radius:4px">'
'<div style="font-size:12px;color:#92400e;font-weight:600;'
'margin-bottom:6px">Cookie-Verhaltens-Check '
'(P59 — deklarierter Zweck vs. tatsaechliches Verhalten)</div>'
'<ul style="margin:0 0 0 18px;padding:0;font-size:11px;color:#1e293b">'
)
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'<li style="margin-bottom:6px">'
f'<span style="display:inline-block;background:{sev_c};color:#fff;'
f'font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px">'
f'{sev}</span><code style="font-size:10px;background:#f1f5f9;'
f'padding:1px 4px;border-radius:2px">{cname}</code>: '
f'{f.get("text", "")[:280]}'
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
f'font-style:italic">Quelle: {f.get("legal_ref", "")} · '
f'Layer {f.get("layer", "?")}</div></li>'
)
parts.append('</ul></div>')
parts.append('</div>')
return "".join(parts)
@@ -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", ""),
@@ -283,6 +283,50 @@ def build_vvt_table_html(vendors: list[dict]) -> str:
summary_parts.append("&mdash; 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'<li><strong>{_flag_short(f)}:</strong> '
f'{rec.get("fix_text", "").splitlines()[0][:180]}</li>'
)
pattern_notice = (
f'<div style="margin:8px 0 12px;padding:10px 14px;'
f'background:#fef3c7;border-left:3px solid #d97706;'
f'border-radius:4px;font-size:11px;color:#92400e">'
f'<strong>Wiederkehrendes Muster ({n_match} von {len(vendors)} '
f'Anbietern, {int(share*100)}%):</strong> '
f'Bei diesen Anbietern fehlen jeweils: '
f'<em>{", ".join(labels)}</em>. '
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'<ul style="margin:8px 0 0 0;padding-left:20px">{"".join(shared_actions)}</ul>'
if shared_actions else '')
+ '</div>'
)
# 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] = [
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
@@ -296,6 +340,7 @@ def build_vvt_table_html(vendors: list[dict]) -> str:
'Verarbeitungen (INTERNAL/GROUP) werden Opt-Out und Privacy-Link '
'NICHT als Pflicht gewertet &mdash; der Widerruf erfolgt ueber das '
'Cookie-Banner, Privacy ist in der Haupt-DSI dokumentiert.</p>',
pattern_notice,
]
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
@@ -389,7 +434,9 @@ def _render_vendor_row_full(v: dict) -> str:
# Inline-Aktions-Anweisungen pro Flag
actions_html = ""
if flags:
# P60: skip per-row actions when already covered by global pattern notice
skip_actions = bool(v.get("_actions_in_global_notice"))
if flags and not skip_actions:
from compliance.services.finding_action_recipes import recipe_for
action_items = []
for f in flags:
@@ -202,52 +202,13 @@ def build_management_summary(results: list[DocCheckResult]) -> 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"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
if "betroffenenrechte" in label_lower:
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
if "verantwortlicher" in label_lower:
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
if "interessenabwaegung" in label_lower:
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
if "loeschkonzept" in label_lower:
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
f"dokumentieren. Aufgabe fuer den DSB.")
if "profiling" in label_lower or "art. 22" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
f"automatisierte Entscheidungen stattfinden oder nicht.")
if "nicht im eingereichten text" in label_lower:
return (f"<strong>{doc_label}:</strong> 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"<strong>{doc_label}:</strong> '{check_label}' muss entfernt werden (Anti-Pattern, rechtlich wirkungslos)."
# Generic fallback
if hint and len(hint) < 150:
return f"<strong>{doc_label}:</strong> {hint[:120]}"
return f"<strong>{doc_label}:</strong> '{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(
@@ -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"<strong>{doc_label}:</strong> 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. <code>_abck</code>) den "
f"konkreten Zweck angeben ('Bot-Detection-Token, gueltig "
f"24h'). DSK-OH Telemedien 2024 §3.2.")
if "speicherdauer" in label_lower:
return (f"<strong>{doc_label}:</strong> 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 &gt; "
f"Application &gt; 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"<strong>{doc_label}:</strong> 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"<strong>{doc_label}:</strong> 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"<strong>{doc_label}:</strong> 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"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
if "betroffenenrechte" in label_lower:
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
if "verantwortlicher" in label_lower:
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
if "interessenabwaegung" in label_lower:
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
if "loeschkonzept" in label_lower:
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
f"dokumentieren. Aufgabe fuer den DSB.")
if "profiling" in label_lower or "art. 22" in label_lower:
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
f"automatisierte Entscheidungen stattfinden oder nicht.")
if "nicht im eingereichten text" in label_lower:
return (f"<strong>{doc_label}:</strong> 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"<strong>{doc_label}:</strong> '{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"<strong>{doc_label}:</strong> {hint[:280]}"
return f"<strong>{doc_label}:</strong> '{check_label}' muss ergaenzt werden."
@@ -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
@@ -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.",
@@ -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).",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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.",
@@ -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": [],
@@ -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
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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)
@@ -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())
@@ -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))
@@ -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())