603381a67f
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 38s
CI / test-python-document-crawler (push) Has been skipped
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 / validate-canonical-controls (push) Successful in 14s
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 / test-python-dsms-gateway (push) Has been skipped
P58 Anti-Audit-Detection robuster (script-domain + settings-spezifisch —
war bereits im Code, jetzt sauber als completed dokumentiert).
P59c DACH-Custom-Cookies in compliance.cookie_library: Borlabs,
etracker, Matomo/Piwik, Userlike, Cookiebot/Cookieyes/Usercentrics,
Akamai/Cloudflare/Datadome Bot-Manager + HubSpot. 21 neue Eintraege
(3 von 24 schon via Open-Cookie-Database vorhanden).
Script: backend-compliance/scripts/seed_dach_cookies.py.
P60b Vendor-Pattern-Dedupe mit Fuzzy-Match (Jaccard >= 0.7) statt exakter
Tuple-Equality. Vendors mit teilweise befuellten Feldern (z.B.
Sitzland eingetragen) fallen nicht mehr aus der globalen Notice —
Bug: Amazon/Psyma/Qualtrics hatten zuvor wiederholte per-row Actions.
P61 "Untergeschobene Cookies"-Erkennung — wenn ein deklarierter Vendor
(z.B. Google Tag Manager) automatisch weitere mitbringt (GA + GCL_AU
+ DoubleClick), werden diese als separater Mail-Block (gelb) mit
COOKIE/VENDOR-Badges + Quellen-Doku ausgewiesen. Neuer Service:
compliance.services.vendor_package_cookies (8 Primary-Vendors mit
je 2-4 implicit Cookies/Vendors).
P62 Marketing-Manager-Disclaimer "Was wir sehen / nicht sehen" als
blauer Box-Block direkt unter dem Critical-Findings-Block. Erklaert
Grenzen unseres Audits (Server-Side-Tracking, Vendor-interne
Datenweitergabe, Cross-Page-Banner) und Risiko des Falschvertrauens
in einen 100%-Score. Neuer Renderer: compliance.api.scope_disclaimer.
Architektur: VVT-Tabellen-Renderer aus agent_doc_check_extras.py (552
LOC -> 242 LOC) in compliance.api.vvt_table_renderer ausgelagert, um den
500-LOC-Hardcap einzuhalten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
14 KiB
Python
286 lines
14 KiB
Python
"""
|
|
P18 — Erweiterter Banner-Block fuer die Email.
|
|
|
|
Rendert die Daten aus dem consent-tester die heute weggeworfen wurden:
|
|
- 3-Phasen-Cookie-Tabelle (before_consent / after_reject / after_accept)
|
|
- Banner-Quality-Score (completeness/correctness/violations)
|
|
- Per-Category-Tracker-Listing
|
|
- Violations-Liste mit Rechtsgrundlagen
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
def _color_for(pct: int) -> str:
|
|
return ("#16a34a" if pct >= 80 else
|
|
"#d97706" if pct >= 50 else "#dc2626")
|
|
|
|
|
|
def _short_phase_label(key: str) -> str:
|
|
return {
|
|
"before_consent": "Vor Consent",
|
|
"after_reject": "Nach Ablehnung",
|
|
"after_accept": "Nach Annahme",
|
|
}.get(key, key)
|
|
|
|
|
|
def _phase_color(key: str, cookie_count: int) -> str:
|
|
if key == "before_consent":
|
|
return "#16a34a" if cookie_count == 0 else "#dc2626"
|
|
if key == "after_reject":
|
|
return "#16a34a" if cookie_count <= 1 else "#d97706"
|
|
return "#94a3b8"
|
|
|
|
|
|
def build_banner_deep_html(banner_result: dict | None) -> str:
|
|
"""Render: Banner-Quality + Phases + Violations.
|
|
|
|
Konsumiert das volle consent-tester-Response. Komplementiert
|
|
`build_provider_list_html` (das nur Summary + TCF-Vendor-Tabelle macht).
|
|
"""
|
|
if not banner_result:
|
|
return ""
|
|
|
|
parts: list[str] = [
|
|
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
|
'max-width:700px;margin:0 auto 16px;padding:14px 18px;'
|
|
'background:#fff;border:1px solid #cbd5e1;border-radius:8px">'
|
|
'<h3 style="margin:0 0 12px;font-size:14px;color:#0f172a">'
|
|
'Cookie-Banner — technische Analyse</h3>'
|
|
]
|
|
|
|
# 1) Quality-Score-Cards
|
|
compl = banner_result.get("completeness_pct")
|
|
corr = banner_result.get("correctness_pct")
|
|
summary = banner_result.get("summary") or {}
|
|
n_critical = summary.get("critical", 0)
|
|
n_high = summary.get("high", 0)
|
|
if compl is not None or corr is not None:
|
|
parts.append(
|
|
'<table style="width:100%;border-collapse:separate;'
|
|
'border-spacing:6px;margin-bottom:10px"><tr>'
|
|
)
|
|
if compl is not None:
|
|
c = _color_for(int(compl))
|
|
parts.append(
|
|
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
|
|
f'border-radius:5px;border-left:3px solid {c}">'
|
|
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
|
|
f'Vollstaendigkeit</div>'
|
|
f'<div style="font-size:18px;font-weight:700;color:{c}">{compl}%</div>'
|
|
f'</td>'
|
|
)
|
|
if corr is not None:
|
|
c = _color_for(int(corr))
|
|
parts.append(
|
|
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
|
|
f'border-radius:5px;border-left:3px solid {c}">'
|
|
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
|
|
f'Korrektheit</div>'
|
|
f'<div style="font-size:18px;font-weight:700;color:{c}">{corr}%</div>'
|
|
f'</td>'
|
|
)
|
|
viol_c = ("#dc2626" if n_critical + n_high > 0 else
|
|
"#d97706" if (summary.get("total_violations") or 0) > 0 else
|
|
"#16a34a")
|
|
parts.append(
|
|
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
|
|
f'border-radius:5px;border-left:3px solid {viol_c}">'
|
|
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
|
|
f'Verstoesse</div>'
|
|
f'<div style="font-size:18px;font-weight:700;color:{viol_c}">'
|
|
f'{summary.get("total_violations", 0)}'
|
|
f'<span style="font-size:11px;color:#64748b;margin-left:6px">'
|
|
f'(crit:{n_critical} high:{n_high})</span></div></td>'
|
|
)
|
|
parts.append('</tr></table>')
|
|
|
|
# 2) 3-Phasen-Tabelle
|
|
phases = banner_result.get("phases") or {}
|
|
if phases:
|
|
parts.append(
|
|
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
|
|
'font-weight:600">Cookie-Setzungen pro Phase '
|
|
'(echter Browser-Test):</div>'
|
|
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
|
|
'margin-bottom:10px;border:1px solid #e2e8f0">'
|
|
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
|
|
'<th style="padding:5px 8px">Phase</th>'
|
|
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
|
|
'<th style="padding:5px 8px;text-align:center">Tracker</th>'
|
|
'<th style="padding:5px 8px">Auffaelligkeiten</th>'
|
|
'</tr></thead><tbody>'
|
|
)
|
|
for key in ("before_consent", "after_reject", "after_accept"):
|
|
ph = phases.get(key) or {}
|
|
if not isinstance(ph, dict): continue
|
|
cookies = ph.get("cookies") or []
|
|
trackers = ph.get("tracking_services") or []
|
|
new_track = ph.get("new_tracking") or []
|
|
violations = ph.get("violations") or []
|
|
undoc = ph.get("undocumented") or []
|
|
color = _phase_color(key, len(cookies))
|
|
issues_parts = []
|
|
if violations: issues_parts.append(f"{len(violations)} Verstoss")
|
|
if new_track: issues_parts.append(f"{len(new_track)} neue Tracker")
|
|
if undoc: issues_parts.append(f"{len(undoc)} undokumentiert")
|
|
issues_str = ", ".join(issues_parts) or "—"
|
|
parts.append(
|
|
f'<tr style="border-top:1px solid #e2e8f0">'
|
|
f'<td style="padding:5px 8px;color:#1e293b;font-weight:600">'
|
|
f'<span style="display:inline-block;width:6px;height:6px;'
|
|
f'border-radius:50%;background:{color};margin-right:6px"></span>'
|
|
f'{_short_phase_label(key)}</td>'
|
|
f'<td style="padding:5px 8px;text-align:center;color:{color};'
|
|
f'font-weight:600">{len(cookies)}</td>'
|
|
f'<td style="padding:5px 8px;text-align:center">{len(trackers)}</td>'
|
|
f'<td style="padding:5px 8px;color:#475569">{issues_str}</td>'
|
|
f'</tr>'
|
|
)
|
|
parts.append('</tbody></table>')
|
|
|
|
# 3) Per-Category-Tracker
|
|
cats = banner_result.get("category_tests") or []
|
|
if cats:
|
|
non_essential = [c for c in cats if c.get("category") != "necessary"]
|
|
if non_essential:
|
|
parts.append(
|
|
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
|
|
'font-weight:600">Provider-Listing pro Banner-Kategorie:</div>'
|
|
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
|
|
'margin-bottom:10px;border:1px solid #e2e8f0">'
|
|
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
|
|
'<th style="padding:5px 8px">Kategorie</th>'
|
|
'<th style="padding:5px 8px;text-align:center">Anbieter</th>'
|
|
'<th style="padding:5px 8px">Hinweis</th>'
|
|
'</tr></thead><tbody>'
|
|
)
|
|
for c in non_essential:
|
|
n = len(c.get("tracking_services") or [])
|
|
label = c.get("category_label") or c.get("category", "?")
|
|
pdv = c.get("provider_details_visible")
|
|
# P19: echtes Signal aus Click-Through-Test
|
|
if pdv is False:
|
|
color, hint = "#dc2626", ("Banner zeigt KEINE Provider-"
|
|
"Details — keine informierte Einwilligung")
|
|
elif pdv is True:
|
|
color, hint = "#16a34a", ""
|
|
elif n == 0:
|
|
color, hint = "#d97706", ("Keine Anbieter erkannt (vermutlich "
|
|
"kein Provider-Listing im Banner)")
|
|
else:
|
|
color, hint = "#16a34a", ""
|
|
parts.append(
|
|
f'<tr style="border-top:1px solid #e2e8f0">'
|
|
f'<td style="padding:5px 8px">{label}</td>'
|
|
f'<td style="padding:5px 8px;text-align:center;color:{color};'
|
|
f'font-weight:600">{n}</td>'
|
|
f'<td style="padding:5px 8px;color:#dc2626;font-size:10px">'
|
|
f'{hint}</td></tr>'
|
|
)
|
|
parts.append('</tbody></table>')
|
|
|
|
# 4) Violations mit Rechtsgrundlage
|
|
violations = (banner_result.get("banner_checks") or {}).get("violations", [])
|
|
if violations:
|
|
parts.append(
|
|
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
|
|
'font-weight:600">Erkannte Banner-Verstoesse:</div>'
|
|
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
|
|
)
|
|
for v in violations[:8]:
|
|
sev = (v.get("severity") or "MEDIUM").upper()
|
|
sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else
|
|
"#d97706" if sev == "MEDIUM" else "#94a3b8")
|
|
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>{v.get("text", "")[:200]}'
|
|
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
|
|
f'font-style:italic">Quelle: {v.get("legal_ref", "")}</div></li>'
|
|
)
|
|
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>')
|
|
|
|
# 6) P61: Untergeschobene Cookies/Vendors (Vendor-Package)
|
|
impl_findings = banner_result.get("implicit_vendor_findings") or []
|
|
if impl_findings:
|
|
# Gruppiert nach primary_vendor: pro Primary die mitgelaufenen Items
|
|
by_primary: dict[str, list[dict]] = {}
|
|
for f in impl_findings:
|
|
by_primary.setdefault(f["primary_vendor"], []).append(f["implicit"])
|
|
parts.append(
|
|
'<div style="margin:14px 0 4px;padding:8px 12px;'
|
|
'background:#fef3c7;border-left:3px solid #d97706;border-radius:4px">'
|
|
'<div style="font-size:12px;color:#92400e;font-weight:600;'
|
|
'margin-bottom:6px">Untergeschobene Cookies / Vendors '
|
|
'(P61 — mit Hauptanbieter automatisch mitgeladen)</div>'
|
|
'<div style="font-size:10px;color:#92400e;margin-bottom:8px">'
|
|
'Diese Cookies/Vendors kommen automatisch mit dem deklarierten '
|
|
'Hauptanbieter mit — Marketing-Manager waehlen sie oft nicht '
|
|
'bewusst aus, sie sind aber zustimmungspflichtig.</div>'
|
|
)
|
|
for primary, impls in by_primary.items():
|
|
parts.append(
|
|
f'<div style="font-size:11px;color:#1e293b;margin:6px 0">'
|
|
f'<strong>{primary}</strong> bringt automatisch:</div>'
|
|
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
|
|
)
|
|
for impl in impls:
|
|
tag = ('<span style="font-size:9px;background:#dc2626;color:#fff;'
|
|
'padding:1px 5px;border-radius:3px;margin-right:6px">'
|
|
'COOKIE</span>' if impl["type"] == "cookie" else
|
|
'<span style="font-size:9px;background:#7c3aed;color:#fff;'
|
|
'padding:1px 5px;border-radius:3px;margin-right:6px">'
|
|
'VENDOR</span>')
|
|
cat_color = {"marketing": "#dc2626", "statistics": "#d97706",
|
|
"functional": "#0891b2", "essential": "#16a34a"}.get(
|
|
impl.get("category", ""), "#475569")
|
|
parts.append(
|
|
f'<li style="margin-bottom:5px">{tag}'
|
|
f'<code style="font-size:10px;background:#f1f5f9;'
|
|
f'padding:1px 4px;border-radius:2px">{impl["name"]}</code> '
|
|
f'<span style="font-size:9px;color:{cat_color};'
|
|
f'margin-left:4px">[{impl.get("category","?")}]</span>'
|
|
f'<div style="font-size:10px;color:#475569;margin-top:2px">'
|
|
f'{impl.get("why","")[:240]}</div>'
|
|
f'<div style="font-size:9px;color:#94a3b8;font-style:italic">'
|
|
f'Quelle: <a href="{impl.get("source_url","")}" '
|
|
f'style="color:#94a3b8">{impl.get("source_url","")[:80]}</a>'
|
|
f'</div></li>'
|
|
)
|
|
parts.append('</ul>')
|
|
parts.append('</div>')
|
|
|
|
parts.append('</div>')
|
|
return "".join(parts)
|