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
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>
331 lines
13 KiB
Python
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}">—</span>')
|
|
return ('<span style="color:#dc2626;font-size:11px" '
|
|
'title="Kein Link">✗</span>')
|
|
if ok:
|
|
return ('<span style="color:#16a34a;font-size:11px" '
|
|
f'title="HTTP {status}">✓</span>')
|
|
status_str = str(status) if status else "?"
|
|
return ('<span style="color:#dc2626;font-size:11px" '
|
|
f'title="HTTP {status_str}">✗ ({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"— {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("— 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 — 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>'
|
|
)
|