Files
breakpilot-compliance/backend-compliance/compliance/api/vvt_table_renderer.py
T
Benjamin Admin 7938e377b6
CI / branch-name (push) Has been skipped
CI / detect-changes (push) Successful in 11s
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 / nodejs-build (push) Has been skipped
CI / test-go (push) Failing after 48s
CI / iace-gt-coverage (push) Successful in 25s
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
feat(audit-tonality): P89/P76/P91 — Co-Pilot statt Roboter-Anwalt
User-Feedback in einer Session: "Wir erzeugen nur Panik. Egal was da steht,
es dauert Wochen. Wir sind Tool an der Seite von CMO/GF/CIO, nicht Gegner."
Memory: feedback_breakpilot_tonalitaet.md (gilt fuer ALLE Module + Marketing).

P89  Critical-Findings-Block ENTFERNT/UMGEBAUT — keine Panik-Rot-Box mehr.
     - Statt "🚨 SOFORTMASSNAHMEN ERFORDERLICH" -> "Zusammenfassung fuer
       die Geschaeftsfuehrung", blauer dezenter Block
     - Statt "VERSTOSSE" -> "Themen zur Besprechung mit DSB, Marketing
       und Entwicklung"
     - Statt "Bussgeldrahmen 4% Weltumsatz" als Erstes -> realistische
       Einordnung (0,1-1%) in dezenter Schluss-Notiz mit Konfidenz-Hinweis
     - "Sofortmassnahme" -> "Empfehlung"
     - "Themen 1, 2, 3..." statt "HIGH"-Badges (P87-Vorbereitung)
     - Explizite Zeitschaetzung "4-8 Wochen (DSB -> Agentur -> Dev -> Freigabe)"

P76  Mercedes-Sekundaer-Buttons (Datenschutzerklaerung + Impressum klein
     unter den 3 Haupt-Buttons) erkennen. Walker scant jetzt label-basiert
     ALLE klickbaren Elemente im Shadow-DOM (wb7-link, wb7-link-secondary,
     wb7-button-text, span[onclick], small a, [role=button], etc.).
     Vermeidet Mercedes-Impressum-False-Positive der Phase 1.

P91  VVT-Tabellen-Renderer in neuer Co-Pilot-Tonalitaet. Statt
     "Verstoss-Liste mit Bussgeldpotenzial" -> Wahrscheinlichkeits-Aussage:
     "Bei Anbieter-Reduktion + Wechsel zu europaeischen Alternativen ist
     Reduktion des Tracking-Footprints + Lizenz-Einsparung wahrscheinlich.
     Fundierte Bewertung erfordert DSB-Abstimmung."

BMW-Bug B1-B4 (P90) bewusst nicht in diesem Commit: BMW-Lauf hat ePaaS
4x captured im consent-tester, aber Backend bekommt 0 cmp_payloads.
Wiring-Bug zwischen consent-tester /dsi-discovery und Backend
_fetch_text — eigene Diagnose-Session noetig (siehe Task P90).

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

331 lines
13 KiB
Python

"""
VVT-Tabelle fuer den Email-Report — pro Vendor eine Zeile, gruppiert
nach Empfaengerkategorie (Art. 30(1)(d) DSGVO).
Ausgelagert aus agent_doc_check_extras.py (LOC-Cap). Enthaelt:
* build_vvt_table_html — Haupteinstieg, gruppiert + summary + P60 notice
* _render_vendor_section / _render_vendor_row_full — Zeilenrenderer
* _link_status_badge / _flag_short — kleine Helper
P60b Fuzzy-Match: Vendors mit teilweise befuellten Feldern (z.B. Sitzland
eingetragen) fallen nicht aus der Pattern-Notice raus, nur weil ihr
Flag-Set um 1-2 Items kleiner ist. Jaccard >= 0.7 deckt das ab.
"""
from __future__ import annotations
def _category_label(kat: str) -> str:
return {
"necessary": "Notwendig", "strictlynecessary": "Notwendig",
"preferences": "Praeferenzen", "functional": "Funktional",
"statistics": "Statistik", "marketing": "Marketing",
"unclassified": "Unklassifiziert",
}.get((kat or "").lower(), kat or "")
def _flag_short(f: str) -> str:
"""Lesbare deutsche Form fuer einen Flag-Token."""
labels = {
"no_cookies_listed": "Cookies fehlen",
"no_country": "Sitzland fehlt",
"no_privacy_url": "Privacy-Link fehlt",
"broken_privacy_url": "Privacy-Link broken",
"no_opt_out_url": "Opt-Out fehlt",
"broken_opt_out": "Opt-Out broken",
}
return labels.get(f, f)
def _link_status_badge(
url: str | None,
ok: bool | None,
status: int | None,
na_label: str | None = None,
) -> str:
if not url:
if na_label:
return ('<span style="color:#94a3b8;font-size:11px" '
f'title="{na_label}">&mdash;</span>')
return ('<span style="color:#dc2626;font-size:11px" '
'title="Kein Link">&#10007;</span>')
if ok:
return ('<span style="color:#16a34a;font-size:11px" '
f'title="HTTP {status}">&#10003;</span>')
status_str = str(status) if status else "?"
return ('<span style="color:#dc2626;font-size:11px" '
f'title="HTTP {status_str}">&#10007; ({status_str})</span>')
def _build_pattern_notice(vendors: list[dict]) -> str:
"""P60 + P60b: globale Notice wenn viele Vendors aehnliche Flag-Sets haben.
Mutiert vendors[].`_actions_in_global_notice` so dass die Zeilenrenderer
redundante per-row-Actions ueberspringen koennen.
"""
from collections import Counter
flag_sets: Counter = Counter()
for v in vendors:
flags = v.get("compliance_flags") or []
if flags:
flag_sets[tuple(sorted(flags))] += 1
if not flag_sets:
return ""
most_common, _ = flag_sets.most_common(1)[0]
most_common_set = set(most_common)
def _similar(flags: tuple) -> bool:
fs = set(flags)
if not fs or not most_common_set:
return False
inter = len(fs & most_common_set)
union = len(fs | most_common_set)
return union > 0 and (inter / union) >= 0.7
n_match = sum(cnt for fs, cnt in flag_sets.items() if _similar(fs))
share = n_match / max(1, len(vendors))
if not (n_match >= 8 and share >= 0.5):
return ""
from compliance.services.finding_action_recipes import recipe_for
labels = [_flag_short(f) for f in most_common]
shared_actions: list[str] = []
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>'
)
for v in vendors:
if _similar(tuple(sorted(v.get("compliance_flags") or []))):
v["_actions_in_global_notice"] = True
return (
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>'
)
def build_vvt_table_html(vendors: list[dict]) -> str:
"""Render per-vendor VVT-style table for the email."""
if not vendors:
return ""
from compliance.services.vendor_classifier import RECIPIENT_TYPE_SECTIONS
by_type: dict[str, list[dict]] = {}
for v in vendors:
rt = (v.get("recipient_type") or "OTHER").upper()
by_type.setdefault(rt, []).append(v)
n_total = len(vendors)
n_internal = sum(
1 for v in vendors
if (v.get("recipient_type") or "").upper() in ("INTERNAL", "GROUP_COMPANY")
)
n_external = n_total - n_internal
n_critical = sum(1 for v in vendors if v.get("compliance_score", 0) < 50)
summary_parts = [f"{n_total} Verarbeitungen erfasst"]
if n_internal and n_external:
summary_parts.append(
f"&mdash; {n_internal} eigene + {n_external} externe Empfaenger"
)
if n_critical:
summary_parts.append(
f', <strong style="color:#dc2626">{n_critical} unter 50%</strong>'
)
else:
summary_parts.append("&mdash; alle ueber 50%")
summary = " ".join(summary_parts)
pattern_notice = _build_pattern_notice(vendors)
out: list[str] = [
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:760px;margin:0 auto 16px;padding:12px 16px;'
'background:#fafafa;border:1px solid #e5e7eb;border-radius:8px">',
'<h3 style="margin:0 0 4px;font-size:14px;color:#334155">'
'Vorschlag fuer das Verarbeitungsverzeichnis (Art. 30 DSGVO)</h3>',
# P91: Co-Pilot-Tonalitaet — Wahrscheinlichkeit statt Garantie,
# Empfehlung statt "Verstoss-Liste".
f'<p style="margin:0 0 8px;font-size:11px;color:#6b7280;line-height:1.5">'
f'Wir haben <strong>{n_total} Verarbeitungen</strong> aus dem '
f'Cookie-Banner abgeleitet, mit unserer globalen Anbieter-Bibliothek '
f'abgeglichen und nach Empfaengerkategorie (Art. 30(1)(d) DSGVO) '
f'gruppiert. Bei einer Reduktion der eingebundenen Anbieter, dem '
f'Wechsel zu europaeischen Alternativen und konsequenter Pruefung '
f'der tatsaechlich benoetigten Cookies ist eine Reduktion des '
f'Tracking-Footprints sowie Lizenz-Einsparungen wahrscheinlich. '
f'Eine fundierte Bewertung erfordert die Abstimmung mit dem '
f'Datenschutzbeauftragten.</p>'
f'<p style="margin:0 0 10px;font-size:11px;color:#6b7280">'
f'{summary}. Innerhalb jeder Gruppe nach Verbesserungspotenzial '
f'sortiert. Bei eigenen Verarbeitungen (INTERNAL/GROUP) sind '
f'Opt-Out und Privacy-Link '
'NICHT als Pflicht gewertet &mdash; der Widerruf erfolgt ueber das '
'nicht erforderlich (Widerruf ueber Banner, Privacy in der '
'Haupt-Datenschutzerklaerung dokumentiert).</p>',
pattern_notice,
]
for rtype, section_label in RECIPIENT_TYPE_SECTIONS:
rows = by_type.get(rtype) or []
if not rows:
continue
rows = sorted(rows, key=lambda v: v.get("compliance_score", 0))
n = len(rows)
n_bad = sum(1 for v in rows if v.get("compliance_score", 0) < 50)
bad_hint = (f' <span style="color:#dc2626">({n_bad} unter 50%)</span>'
if n_bad else "")
out.append(
f'<h4 style="margin:14px 0 4px;font-size:12px;color:#1e293b;'
f'border-top:1px solid #e2e8f0;padding-top:8px">'
f'{section_label} <span style="color:#94a3b8;font-weight:400">'
f'({n}){bad_hint}</span></h4>'
)
out.append(_render_vendor_section(rows))
out.append('</div>')
return "".join(out)
def _render_vendor_section(rows: list[dict]) -> str:
body: list[str] = [
'<table style="width:100%;border-collapse:collapse;font-size:11px">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Name</th>'
'<th style="padding:5px 8px">Kategorie</th>'
'<th style="padding:5px 8px">Sitz</th>'
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
'<th style="padding:5px 8px;text-align:center">Opt-Out</th>'
'<th style="padding:5px 8px;text-align:center">Privacy</th>'
'<th style="padding:5px 8px;text-align:right">Score</th>'
'</tr></thead><tbody>',
]
for v in rows:
body.append(_render_vendor_row_full(v))
body.append('</tbody></table>')
return "".join(body)
def _render_vendor_row_full(v: dict) -> str:
rtype = (v.get("recipient_type") or "OTHER").upper()
is_own = rtype in ("INTERNAL", "GROUP_COMPANY")
cat = (v.get("category") or "").lower()
is_necessary = cat in ("necessary", "strictlynecessary")
name = v.get("name") or "Unbekannt"
category = _category_label(v.get("category", ""))
country = v.get("country") or ""
cookies = v.get("cookies") or []
n_cookies = len(cookies)
score = int(v.get("compliance_score", 0))
flags = v.get("compliance_flags") or []
opt_na_reason = ("Nicht erforderlich (eigene Verarbeitung — "
"Widerruf ueber Cookie-Banner)") if is_own else (
"Nicht erforderlich (§25 Abs. 2 TDDDG — technisch notwendig)"
if is_necessary else None
)
opt_status = _link_status_badge(
v.get("opt_out_url"), v.get("opt_out_ok"), v.get("opt_out_status"),
na_label=opt_na_reason,
)
privacy_na_reason = (
"Nicht erforderlich (eigene Verarbeitung — durch Haupt-DSI abgedeckt)"
if is_own else None
)
privacy_status = _link_status_badge(
v.get("privacy_policy_url"), v.get("privacy_ok"),
v.get("privacy_status"), na_label=privacy_na_reason,
)
score_color = ("#16a34a" if score >= 80 else
"#d97706" if score >= 50 else "#dc2626")
n_criteria = 3 if is_own else 5
n_failed = len(flags) if flags else 0
score_tooltip = (
f"{n_criteria - n_failed} von {n_criteria} Kriterien erfuellt"
+ (f" — fehlt: {', '.join(_flag_short(f) for f in flags[:3])}"
if flags else "")
)
actions_html = ""
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:
rec = recipe_for(f)
if not rec:
continue
action_items.append(
f'<li style="margin-bottom:6px"><strong>{_flag_short(f)}:</strong> '
f'{rec.get("what", "")}<br/>'
f'<span style="color:#475569"><strong>Was tun:</strong> '
f'{rec.get("fix_text", "").splitlines()[0][:200]}</span><br/>'
f'<span style="color:#94a3b8;font-size:9px">Quelle: '
f'{rec.get("why", "")[:160]}</span></li>'
)
if action_items:
actions_html = (
f'<details style="margin-top:4px"><summary style="cursor:pointer;'
f'color:#dc2626;font-size:10px">Was muss ich tun? '
f'({len(action_items)} Action{"s" if len(action_items) != 1 else ""})</summary>'
f'<ul style="margin:4px 0 0 14px;padding:0;font-size:10px;color:#1e293b">'
+ "".join(action_items)
+ '</ul></details>'
)
flag_str = ""
if flags:
flag_str = (
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px">'
f'{", ".join(flags[:4])}</div>'
f'{actions_html}'
)
risk = v.get("compliance_risk") or {}
risk_label = risk.get("label") or ""
risk_badge = ""
if risk_label and risk_label != "unklar":
rc = {
"kritisch": ("#dc2626", "#fff"),
"hoch": ("#fecaca", "#991b1b"),
"mittel": ("#fde68a", "#92400e"),
"gering": ("#d1fae5", "#065f46"),
}.get(risk_label, ("#e5e7eb", "#475569"))
risk_badge = (f'<span style="margin-left:6px;padding:1px 5px;border-radius:3px;font-size:9px;'
f'background:{rc[0]};color:{rc[1]}">Risk: {risk_label}</span>')
return (
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:6px 8px;color:#1e293b;font-size:11px">'
f'{name}{risk_badge}{flag_str}</td>'
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{category}</td>'
f'<td style="padding:6px 8px;color:#475569;font-size:11px">{country}</td>'
f'<td style="padding:6px 8px;text-align:center;color:#475569;font-size:11px">'
f'{n_cookies}</td>'
f'<td style="padding:6px 8px;text-align:center">{opt_status}</td>'
f'<td style="padding:6px 8px;text-align:center">{privacy_status}</td>'
f'<td style="padding:6px 8px;text-align:right;font-weight:600;'
f'color:{score_color};font-size:11px" title="{score_tooltip}">'
f'{score}%<div style="font-size:9px;font-weight:400;color:#94a3b8">'
f'{n_criteria - n_failed}/{n_criteria}</div></td>'
f'</tr>'
)