Files
breakpilot-compliance/backend-compliance/compliance/services/audit_quality_checks.py
T
Benjamin Admin 081e4f057a
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 55s
CI / iace-gt-coverage (push) Successful in 25s
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m43s
feat(audit): Cookie-Compliance-Audit (3-Quellen-Vergleich) + Vendor-Dedup + Block-Parser
ZENTRALER USP: cookie_compliance_audit.py vergleicht 3 Quellen
* DEKLARIERT in Cookie-Richtlinie (parse_cookie_table + parse_flat)
* TATSAECHLICH im Browser geladen (banner_result.phases.after_accept)
* LIBRARY-Metadaten (cookie_library lookup)

Liefert 3 Listen mit Compliance-Verdict:
* compliant (deklariert UND geladen) — gruener Block
* undeclared_in_browser (geladen NICHT deklariert) — ROTER HIGH-Block
  → Art. 13(1)(c) DSGVO + § 25 TDDDG Verstoss
* declared_not_loaded (deklariert NICHT geladen) — gelber Hinweis
  → Tabelle moeglicherweise veraltet

parse_cookie_table erweitert um Block-Format (5 Zeilen pro Cookie wie
beim User-Copy aus VW). Findet 35+ Cookies aus Copy-Paste statt 0.

vendor_normalizer.py: 50+ Aliases (Google-Familie, Adobe-Familie,
Trade Desk, AdForm, ...) + Garbage-Filter (URLs, leere Strings,
'click to select', 'Mehrere OEMs'). Mergt cookies-Listen beim Dedup.

_guess_vendor erweitert: Adobe-Familie (s_ecid/AMCV/demdex/mbox/...),
Trade Desk (TDID/TDCPM/TTDOptOut), AdForm (uid/cid/otsid),
Salesforce LiveAgent, etracker, Akamai, EDAA.

audit_quality_checks: vendor-thin-Threshold jetzt dynamisch nach
Cookie-Doc-Wörter (3k→10 / 6k→20 / 10k→30 / 15k+→40).

VW-Test-Fixture: tests/fixtures/cookie_gt/vw_cookie_richtlinie.txt
(36-Cookie-Sample fuer Regression-Tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:36:45 +02:00

214 lines
8.3 KiB
Python

"""
A — Audit-Transparenz / Audit-Quality-Checks.
Wenn der Crawler nicht alles gefunden hat, MUSS die Mail das prominent
zeigen — sonst denkt der User 'alles gut' obwohl die Datenlage Luecken
hat.
Erkennt 4 Quality-Failures:
1. banner_detected=False trotz vorhandenem Cookie-Doc → CMP-Tool ungeladen
2. cookie_doc >= 30k chars aber cmp_vendors < 10 → Vendor-Extract unvollstaendig
3. doc_text submitted aber 0 chars geladen → Crawler-Failure
4. cmp_vendors > 0 aber alle aus llm_cascade ohne Library-Match → vermutl. unvollstaendig
Diese Findings landen IMMER im GF-1-Pager (auch wenn kein anderes
HIGH-Finding da ist) — sie sagen "die Datenlage ist unvollstaendig,
manuelle Pruefung empfohlen".
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def _word_count(text: str | None) -> int:
if not text:
return 0
return len(text.split())
def check_banner_not_detected(
banner_result: dict | None,
cookie_doc_text: str | None,
) -> dict | None:
"""1) Banner nicht geladen aber Cookie-Doc vorhanden → CMP-Tool kaputt."""
if not isinstance(banner_result, dict):
return None
detected = banner_result.get("banner_detected")
if detected is None or detected is True:
return None
if not cookie_doc_text or len(cookie_doc_text) < 5000:
return None
return {
"severity": "HIGH",
"code": "audit_banner_not_detected",
"label": "Audit-Vorbehalt: Cookie-Banner konnte vom Crawler nicht "
"geladen werden",
"area": "Cookie-Banner",
"owner": "DSB + Marketing/CMP-Admin",
"detail": (
"Unser Crawler konnte das CMP-Tool dieser Site nicht analysieren — "
"weder Vendor-Liste noch Cookie-Verhalten konnten geprueft werden. "
"Moegliche Ursachen: Anti-Bot-Schutz (Akamai/Cloudflare/DataDome) "
"blockiert Playwright; das CMP-Skript laed nur fuer bestimmte "
"Geo-Regionen; ein neues CMP-Tool das wir noch nicht unterstuetzen. "
"Empfehlung: manuelle Pruefung des Banners durch DSB, alternativ "
"Cookie-Tabelle im Audit-Tool direkt einfuegen (Copy-Paste-Modus)."
),
"legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht — der Audit-"
"Befund muss transparent zwischen 'geprueft & OK' und "
"'nicht pruefbar' unterscheiden.",
}
def check_vendor_extract_incomplete(
cookie_doc_text: str | None,
cmp_vendors: list | None,
) -> dict | None:
"""2) Cookie-Doc gross aber wenig Vendors → Extract unvollstaendig.
Dynamische Schwelle nach Doc-Groesse:
* 3k-6k Wörter → mind. 10 Vendors erwartet
* 6k-10k Wörter → mind. 20 Vendors
* 10k-15k Wörter → mind. 30 Vendors
* 15k+ Wörter → mind. 40 Vendors
"""
wc = _word_count(cookie_doc_text)
n_vendors = len(cmp_vendors or [])
if wc < 3000:
return None
# Erwartete Vendor-Anzahl heuristisch nach Doc-Groesse
if wc >= 15000:
expected = 40
elif wc >= 10000:
expected = 30
elif wc >= 6000:
expected = 20
else:
expected = 10
if n_vendors >= expected:
return None
return {
"severity": "HIGH" if wc >= 8000 else "MEDIUM",
"code": "audit_vendor_extract_thin",
"label": (
f"Audit-Vorbehalt: Cookie-Richtlinie hat {wc:,} Wörter, "
f"erwartet ~{expected} Vendors, extrahiert nur {n_vendors}"
).replace(",", "."),
"area": "Vendor-Liste / VVT",
"owner": "DSB + Marketing",
"detail": (
f"Bei einer Cookie-Richtlinie mit {wc:,} Woertern erwarten wir "
f"typischerweise {expected}+ unique Vendors. Die extrahierte Zahl "
f"({n_vendors}) ist auffaellig niedrig — entweder hat unser "
"Parser/LLM die Tabelle nicht vollstaendig erfasst oder "
"Vendors wurden zu konservativ erkannt. Empfehlung: Cookie-"
"Tabelle im Copy-Paste-Modus einreichen (Frontend-Toggle "
"'Text einfuegen' pro Cookie-Doc-Zeile) — dort parsen wir "
"Spalten deterministisch."
).replace(",", "."),
"legal_basis": "Art. 13(1)(e) DSGVO — die Empfaengerliste muss "
"vollstaendig sein; ein unvollstaendiger Audit darf "
"nicht als vollstaendig dargestellt werden.",
}
def check_url_fetch_failed(doc_entries: list | None) -> list[dict]:
"""3) Submitted URL aber 0 oder Mini-Text → Crawler-Failure pro Doc."""
out: list[dict] = []
for e in (doc_entries or []):
if not isinstance(e, dict):
continue
url = (e.get("url") or "").strip()
text = (e.get("text") or "").strip()
if not url or len(text) >= 200 or e.get("auto_discovered"):
continue
dt = e.get("doc_type", "doc")
rejected = e.get("rejected_url") or ""
out.append({
"severity": "MEDIUM",
"code": f"audit_url_fetch_failed_{dt}",
"label": (
f"Audit-Vorbehalt: {dt}-URL konnte nicht geladen werden "
f"({len(text)} Zeichen extrahiert)"
),
"area": dt,
"owner": "DSB + Web-Team",
"detail": (
f"Die eingegebene URL {url[:120]} lieferte weniger als 200 "
"Zeichen. Moegliche Ursachen: 404, JS-only Render, Anti-Bot, "
"Cookie-Wall. Auto-Discovery hat versucht eine Alternative "
"auf der Homepage zu finden — ohne Erfolg. Empfehlung: "
"korrekte URL pruefen oder den Text direkt einfuegen "
"(Copy-Paste-Modus)."
),
"legal_basis": "Art. 5 (2) DSGVO Rechenschaftspflicht.",
})
return out
def run_all(
banner_result: dict | None,
cookie_doc_text: str | None,
cmp_vendors: list | None,
doc_entries: list | None,
) -> list[dict]:
findings: list[dict] = []
try:
f1 = check_banner_not_detected(banner_result, cookie_doc_text)
if f1:
findings.append(f1)
except Exception as e:
logger.warning("audit_banner_not_detected failed: %s", e)
try:
f2 = check_vendor_extract_incomplete(cookie_doc_text, cmp_vendors)
if f2:
findings.append(f2)
except Exception as e:
logger.warning("audit_vendor_extract_thin failed: %s", e)
try:
findings.extend(check_url_fetch_failed(doc_entries))
except Exception as e:
logger.warning("audit_url_fetch_failed failed: %s", e)
return findings
def build_audit_quality_block_html(findings: list[dict]) -> str:
if not findings:
return ""
items: list[str] = []
for f in findings:
sev = f.get("severity", "MEDIUM")
sev_color = "#dc2626" if sev == "HIGH" else "#d97706"
items.append(
f'<li style="margin-bottom:10px;font-size:11px;line-height:1.5">'
f'<strong style="color:{sev_color}">[{sev}] {f.get("label","")}</strong>'
f'<div style="color:#475569;margin-top:3px">{f.get("detail","")}</div>'
f'<div style="color:#94a3b8;margin-top:2px;font-style:italic">'
f'{f.get("legal_basis","")}</div>'
f'</li>'
)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:14px 18px;'
'background:#fee2e2;border:1px solid #fecaca;border-radius:8px">'
'<div style="font-size:11px;color:#991b1b;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Audit-Vorbehalt — Datenlage unvollstaendig</div>'
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
f'{len(findings)} Punkt'
f'{"e" if len(findings) != 1 else ""} bei denen der Audit selbst '
f'an Grenzen gestossen ist</h3>'
'<p style="margin:0 0 10px;font-size:11px;color:#475569;line-height:1.5">'
'Die folgenden Punkte betreffen NICHT die Compliance Ihrer Website, '
'sondern die Vollstaendigkeit unserer Pruefung. Bei diesen Bereichen '
'sollten Sie den Audit nicht als "alles ok" werten, sondern manuell '
'oder im Copy-Paste-Modus nachpruefen.'
'</p>'
'<ul style="margin:0 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)