Files
breakpilot-compliance/backend-compliance/compliance/services/banner_consistency_checks.py
T
Benjamin Admin df8832c521
CI / detect-changes (push) Successful in 11s
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 / validate-canonical-controls (push) Successful in 15s
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) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
feat(audit): P75 Banner-vs-CMP + P84 Diff-Mode + P74/P96/P97 Doc-Types
P75 — check_banner_vs_cmp_partner_count: wenn Banner-Text 'N Partner'
nennt und N < cmp_vendors * 0.6, HIGH-Finding (Art. 13(1)(e) DSGVO).
Erkennt Verharmlosung der tatsaechlichen Vendor-Anzahl.

P84 — run_diff.py: vergleicht aktuellen Lauf mit letztem Snapshot
derselben Site (set-Diff auf normalisierten Finding-Labels). Block
ueber dem GF-1-Pager: 'Seit letztem Lauf: X Findings weg, Y neue'.
USP — keiner der grossen Anbieter hat das.

P74/P96/P97 — Labels fuer legal_notice (Rechtliche Hinweise / IP /
Forward-Looking), dsa (Art. 12+17 Digital Services Act), lizenzhinweise
(OSS-Compliance) in _DOC_TYPE_LABELS registriert. Echte Pflichtangaben-
Checks kommen separat.

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

280 lines
10 KiB
Python

"""
P92 + P94 — Banner-Konsistenz-Checks (Post-hoc auf banner_result).
P92 — CMP-Tool-Verfuegbarkeit:
Wenn "Anpassen"/"Einstellungen" angeklickt wurde und das Tool laed
nicht (Network-Error, Timeout, weisse Seite, fehlende
consent-Elemente nach Klick), ist das ein HIGH-Verstoss — der
Nutzer hat formal die Moeglichkeit zur granularen Wahl, aber sie
funktioniert nicht.
P94 — Banner-Init-vs-Cookie-Footer-Konsistenz:
Cookie-Liste im Initial-Banner-Settings darf nicht von der Liste
im permanenten Cookie-Richtlinien-Dokument abweichen. Wenn Banner
12 Cookies nennt, die Cookie-Doc aber 47, ist mindestens eine der
beiden Quellen unvollstaendig → MEDIUM-Finding.
Beide liefern dict mit shape:
{"severity": "HIGH"|"MEDIUM", "code": str, "label": str, "detail": str}
oder None, wenn der Check nicht greift.
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_ANPASSEN_KEYS = (
"anpassen", "einstellungen", "customize", "preferences",
"settings", "individuelle", "auswahl", "manage",
)
def _phases(banner_result: dict) -> dict:
if not isinstance(banner_result, dict):
return {}
return banner_result.get("phases") or {}
def check_cmp_tool_availability(banner_result: dict) -> dict | None:
"""P92 — Anpassen-Klick aber Settings-Tool defekt / leer."""
phases = _phases(banner_result)
settings_ph = phases.get("settings") or phases.get("after_settings_click")
if not isinstance(settings_ph, dict):
return None
initial_ph = phases.get("initial") or phases.get("before_accept") or {}
initial_text = (initial_ph.get("banner_text") or "").lower()
if not any(k in initial_text for k in _ANPASSEN_KEYS):
return None # Wenn kein Anpassen-Button gar nicht im Initial-Banner,
# ist das P100s Job — nicht hier doppelt melden.
error = settings_ph.get("error") or settings_ph.get("status_error")
settings_text = (settings_ph.get("banner_text") or "").strip()
has_categories = bool(
settings_ph.get("categories")
or settings_ph.get("category_tests")
or (settings_ph.get("structured_checks") or [])
)
has_toggles = bool(re.search(r"checkbox|toggle|switch|aria-checked",
(settings_ph.get("banner_html") or ""), re.I))
timed_out = bool(settings_ph.get("timeout"))
failure_signals: list[str] = []
if error:
failure_signals.append(f'Fehler: {str(error)[:120]}')
if timed_out:
failure_signals.append('Zeitueberschreitung beim Laden')
if len(settings_text) < 80 and not has_categories:
failure_signals.append(
f'Settings-Bereich nur {len(settings_text)} Zeichen, '
'keine Kategorien sichtbar'
)
if not has_toggles and not has_categories:
failure_signals.append(
'Keine Checkboxen / Toggles im Settings-Bereich'
)
if not failure_signals:
return None
return {
"severity": "HIGH",
"code": "cmp_tool_unavailable",
"label": 'Cookie-Einstellungen ueber "Anpassen" formal vorhanden, '
'Tool laed aber nicht oder ist leer',
"detail": " | ".join(failure_signals),
"legal_basis": "Art. 7 (3) DSGVO + EDPB 03/2022 — die Moeglichkeit "
"zur granularen Auswahl muss tatsaechlich funktionieren.",
}
def _normalize_cookie_names(items) -> set[str]:
out: set[str] = set()
if not items:
return out
for it in items:
if isinstance(it, str):
name = it.strip()
elif isinstance(it, dict):
name = (it.get("name") or it.get("cookie") or it.get("id") or "").strip()
else:
continue
if name and len(name) <= 120:
out.add(name.lower())
return out
def check_init_banner_vs_cookie_doc(
banner_result: dict,
cookie_doc_text: str | None,
) -> dict | None:
"""P94 — Cookie-Liste im Init-Banner vs in der Cookie-Richtlinie."""
if not cookie_doc_text or len(cookie_doc_text) < 500:
return None
phases = _phases(banner_result)
banner_cookies = _normalize_cookie_names(
(phases.get("settings") or {}).get("cookies") or []
) | _normalize_cookie_names(
(phases.get("initial") or phases.get("before_accept") or {}).get("cookies") or []
)
# Aus dem Cookie-Doc-Text: Cookie-Namen sind typischerweise
# camelCase oder _underscored, 4-40 Zeichen, ohne Leerzeichen.
candidates = set(re.findall(
r"\b([A-Za-z_][A-Za-z0-9_\-\.]{3,40})\b", cookie_doc_text
))
# Filter: heuristisch wahrscheinliche Cookie-Namen
doc_cookies: set[str] = set()
for c in candidates:
cl = c.lower()
if any(p in cl for p in (
"_ga", "_gid", "_gcl", "_fbp", "uc_", "ot_",
"cookieconsent", "sessionid", "csrf", "ajs_", "amp_",
"datadome", "incap_", "_pk_", "wp-", "yt-",
)):
doc_cookies.add(cl)
elif re.match(r"^[a-z][a-z0-9_]{3,30}$", cl) and (
"cookie" in cl or "consent" in cl or "track" in cl or "session" in cl
):
doc_cookies.add(cl)
if len(doc_cookies) < 5 or not banner_cookies:
return None # Datenlage zu duenn fuer sinnvolle Aussage.
only_in_doc = doc_cookies - banner_cookies
only_in_banner = banner_cookies - doc_cookies
if len(only_in_doc) < 5 and len(only_in_banner) < 3:
return None # Tolerable Abweichung.
severity = "MEDIUM"
# HIGH wenn beide Seiten massiv abweichen — dann fehlt klar
# die Cross-Reference.
if len(only_in_doc) >= 15 and len(only_in_banner) >= 5:
severity = "HIGH"
return {
"severity": severity,
"code": "banner_cookie_doc_mismatch",
"label": (
f"Cookie-Liste im Banner-Einstellungen ({len(banner_cookies)}) "
f"weicht von Cookie-Richtlinie ({len(doc_cookies)}) ab"
),
"detail": (
f"Nur im Cookie-Dokument: {len(only_in_doc)} Cookies (Beispiele: "
f"{', '.join(sorted(only_in_doc)[:5])}). "
f"Nur im Banner: {len(only_in_banner)} Cookies. "
"Empfehlung: eine der beiden Quellen als Single-Source-of-Truth "
"definieren und die andere automatisch generieren."
),
"legal_basis": (
"Art. 13(1)(c) DSGVO + Art. 12 DSGVO — Informationen ueber die "
"Verarbeitung muessen vollstaendig und konsistent sein."
),
}
def check_banner_vs_cmp_partner_count(
banner_result: dict,
cmp_vendors: list | None,
) -> dict | None:
"""P75 — Banner nennt N Partner, CMP-Payload listet viel mehr.
Wenn der Banner-Text behauptet "5 Partner" oder "Wir und unsere
Partner", die CMP-Payload aber 100+ Vendors enthaelt, wird der
User getaeuscht.
"""
cmp_count = len(cmp_vendors or [])
if cmp_count < 20:
return None
initial_ph = (_phases(banner_result).get("initial")
or _phases(banner_result).get("before_accept") or {})
banner_text = (initial_ph.get("banner_text") or "")[:5000]
if not banner_text:
return None
m = re.search(r"\b(\d{1,4})\s*(?:partner|drittanbieter|vendor|"
r"anbieter|dienstleister)", banner_text, re.I)
if not m:
return None
claimed = int(m.group(1))
if claimed >= cmp_count * 0.6:
return None # Zahl im Banner ist plausibel.
return {
"severity": "HIGH",
"code": "banner_understates_vendor_count",
"label": (
f"Banner-Text nennt {claimed} Partner, CMP-Payload listet "
f"{cmp_count} Vendors"
),
"detail": (
f"Die im Banner-Text genannte Zahl ({claimed}) unterschaetzt die "
f"tatsaechliche Anzahl der Empfaenger ({cmp_count}) deutlich. "
"Empfehlung: Banner-Text auf die echte Vendor-Zahl heben oder "
"die Vendor-Liste reduzieren."
),
"legal_basis": (
"Art. 13(1)(e) DSGVO + EDPB 5/2020 — die Empfaenger / "
"Empfaengerkategorien muessen vollstaendig und nicht "
"verharmlosend angegeben sein."
),
}
def run_all(banner_result: dict, cookie_doc_text: str | None = None,
cmp_vendors: list | None = None) -> list[dict]:
findings: list[dict] = []
try:
f1 = check_cmp_tool_availability(banner_result)
if f1:
findings.append(f1)
except Exception as e:
logger.warning("P92 cmp_tool_availability failed: %s", e)
try:
f2 = check_init_banner_vs_cookie_doc(banner_result, cookie_doc_text)
if f2:
findings.append(f2)
except Exception as e:
logger.warning("P94 init_vs_cookie_doc failed: %s", e)
try:
f3 = check_banner_vs_cmp_partner_count(banner_result, cmp_vendors)
if f3:
findings.append(f3)
except Exception as e:
logger.warning("P75 banner_vs_cmp_count failed: %s", e)
return findings
def build_consistency_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:#fef3c7;border:1px solid #fcd34d;border-radius:8px">'
'<div style="font-size:11px;color:#92400e;text-transform:uppercase;'
'letter-spacing:1.2px;margin-bottom:4px;font-weight:600">'
'Banner-Konsistenz-Pruefung</div>'
f'<h3 style="margin:0 0 6px;font-size:14px;color:#1e293b">'
f'{len(findings)} Konsistenz-Finding{"s" if len(findings) != 1 else ""} '
'zwischen Banner-UI und Cookie-Richtlinie</h3>'
'<ul style="margin:8px 0 0 18px;padding:0">'
+ "".join(items) +
'</ul></div>'
)