From 6d29191e9b761cb8bfed797f2969c7482e64bb0c Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 17 May 2026 13:15:40 +0200 Subject: [PATCH] fix(vvt): score INTERNAL/GROUP without opt-out/privacy penalty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback after BMW test: - 60 'BMW AG — XYZ' rows were rendered as ✗ for Opt-Out/Privacy and scored 38-52%. That's misleading: BMW processing for itself doesn't need a separate opt-out URL (cookie-banner is the consent mechanism) or a separate privacy policy (main DSI covers it). - Title 'Anbieter' was wrong for 60 of 90 rows (internal services). Three orthogonal fixes: 1. score_vendors becomes recipient_type aware: - INTERNAL/GROUP_COMPANY: opt_out_url, privacy_policy_url, country are NOT required (the user's main DSI + cookie-banner cover them). What IS required: name, purpose, cookies disclosed with name + expiry. Cookies-disclosure weight raised to 50 (was 15) so the VVT-relevant data is the score driver. - 'necessary' category: opt-out still skipped (§25 Abs. 2 TDDDG). - External (PROCESSOR/CONTROLLER): existing strict scoring stays. 2. _link_status_badge accepts na_label and renders a neutral em-dash with explanation tooltip instead of red ✗ when the column doesn't apply to that row. _render_vendor_row_full passes na_label based on recipient_type: - INTERNAL/GROUP -> 'Nicht erforderlich (eigene Verarbeitung)' - necessary -> 'Nicht erforderlich (§25 Abs. 2 TDDDG)' 3. Header + summary clarify the split: - h3 changed to 'Verarbeitungstaetigkeiten und Empfaenger aus der Cookie-Richtlinie' (was 'Drittanbieter aus Cookie-Richtlinie'). - Top line: '90 Verarbeitungen erfasst — 60 eigene + 30 externe Empfaenger'. - Disclaimer below: explains the INTERNAL/GROUP exemption so the reader understands why those rows don't show ✗ for missing URLs. - Section labels enriched with the relevant DSGVO article: 'Eigene Verarbeitungstaetigkeiten — fuer das VVT (Art. 30)', 'Auftragsverarbeiter — AVV erforderlich (Art. 28)', 'Joint Controller — Vereinbarung pruefen (Art. 26)'. Expected BMW result after fix: ~85% of the 60 BMW-AG rows jump from ~52% to 90-100% (the real issue, fehlende Cookies-Disclosure, stays flagged). The only true findings remaining are external links that return 4xx (e.g. Criteo 403, Teads 404). --- .../compliance/api/agent_doc_check_extras.py | 77 +++++++++++++++---- .../services/cookie_link_validator.py | 62 +++++++++------ .../compliance/services/vendor_classifier.py | 8 +- 3 files changed, 108 insertions(+), 39 deletions(-) diff --git a/backend-compliance/compliance/api/agent_doc_check_extras.py b/backend-compliance/compliance/api/agent_doc_check_extras.py index 77497f9a..7a4f30f5 100644 --- a/backend-compliance/compliance/api/agent_doc_check_extras.py +++ b/backend-compliance/compliance/api/agent_doc_check_extras.py @@ -264,22 +264,38 @@ def build_vvt_table_html(vendors: list[dict]) -> str: # Top summary 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 = ( - f"{n_total} Anbieter erfasst" - + (f", {n_critical} unter 50%" - if n_critical else " — alle ueber 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', {n_critical} unter 50%' + ) + else: + summary_parts.append("— alle ueber 50%") + summary = " ".join(summary_parts) out: list[str] = [ '
', '

' - 'VVT-Vorschlag: Drittanbieter aus Cookie-Richtlinie

', + 'VVT-Vorschlag: Verarbeitungstaetigkeiten und Empfaenger aus der ' + 'Cookie-Richtlinie', f'

{summary}. ' - 'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO), innerhalb ' - 'jeder Gruppe nach Compliance-Score sortiert.

', + 'Gruppiert nach Empfaengerkategorie (Art. 30(1)(d) DSGVO). Innerhalb ' + 'jeder Gruppe nach Compliance-Score sortiert. Bei eigenen ' + 'Verarbeitungen (INTERNAL/GROUP) werden Opt-Out und Privacy-Link ' + 'NICHT als Pflicht gewertet — der Widerruf erfolgt ueber das ' + 'Cookie-Banner, Privacy ist in der Haupt-DSI dokumentiert.

', ] for rtype, section_label in RECIPIENT_TYPE_SECTIONS: @@ -323,19 +339,38 @@ def _render_vendor_section(rows: list[dict]) -> str: 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 "—" + country = v.get("country") or ("—" if is_own else "—") cookies = v.get("cookies") or [] n_cookies = len(cookies) score = int(v.get("compliance_score", 0)) flags = v.get("compliance_flags") or [] + + # Opt-Out: nicht erforderlich fuer eigene Verarbeitung oder + # technisch notwendige Cookies (§25 Abs. 2 TDDDG). + 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: nicht erforderlich fuer eigene Verarbeitung (Haupt-DSI). + 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"), + v.get("privacy_status"), na_label=privacy_na_reason, ) score_color = ("#16a34a" if score >= 80 else "#d97706" if score >= 50 else "#dc2626") @@ -361,10 +396,26 @@ def _render_vendor_row_full(v: dict) -> str: ) -def _link_status_badge(url: str | None, ok: bool | None, status: int | None) -> str: +def _link_status_badge( + url: str | None, + ok: bool | None, + status: int | None, + na_label: str | None = None, +) -> str: + """Render the link-status cell. + + - url + ok -> green check + - url + broken -> red cross with status + - no url + na_label -> neutral em-dash with explanation tooltip + (used for INTERNAL/necessary rows where the field isn't required) + - no url + no na_label -> red cross (real gap) + """ if not url: - return ('' - '✗') + if na_label: + return ('') + return ('') if ok: return ('') diff --git a/backend-compliance/compliance/services/cookie_link_validator.py b/backend-compliance/compliance/services/cookie_link_validator.py index 42888b30..a1407532 100644 --- a/backend-compliance/compliance/services/cookie_link_validator.py +++ b/backend-compliance/compliance/services/cookie_link_validator.py @@ -173,16 +173,34 @@ async def validate_vendor_urls(vendors: list[dict]) -> list[dict]: def score_vendors(vendors: list[dict]) -> list[dict]: - """Compute per-vendor compliance score (0-100) and flags. Mutates. + """Compute per-vendor compliance score (0-100) and flags. - Category-aware: 'necessary' (technisch erforderliche Cookies) do NOT - require an opt-out — §25 Abs. 2 TDDDG. Penalising them for that would - be wrong; instead we require precise purpose + cookie disclosure. + Scoring is recipient-type AND category aware. Two orthogonal axes + influence which fields are required: + + recipient_type == INTERNAL / GROUP_COMPANY + Own processing — the user's consent + main DSI cover privacy + + opt-out for ALL of these. Per-row opt-out / privacy URLs are + NOT a compliance gap. What matters: VVT-relevante Fields + (purpose, cookies with names + expiry). + + category == 'necessary' (§25 Abs. 2 TDDDG) + Technically necessary cookies don't need consent → no opt-out + required even for external processors. + + For each non-applicable field we set flag '_n_a' instead of + a penalty flag, so the report can render it neutrally. """ for v in vendors: + rtype = (v.get("recipient_type") or "OTHER").upper() + is_own = rtype in ("INTERNAL", "GROUP_COMPANY") is_necessary = (v.get("category") or "").lower() in ( "necessary", "strictlynecessary", ) + opt_out_required = not is_own and not is_necessary + privacy_required = not is_own + country_required = not is_own + score = 0 max_score = 0 flags: list[str] = [] @@ -201,17 +219,16 @@ def score_vendors(vendors: list[dict]) -> list[dict]: else: flags.append("no_purpose") - # Country (3rd-country transfer relevance) — only relevant for - # consent-based categories (otherwise irrelevant flag noise) - if not is_necessary: + # Country — only for external processors / controllers + if country_required: max_score += 10 if v.get("country"): score += 10 else: flags.append("no_country") - # Opt-Out URL — only for consent-based categories (§25 TDDDG) - if not is_necessary: + # Opt-Out URL — only when consent-based AND external + if opt_out_required: max_score += 25 if not v.get("opt_out_url"): flags.append("no_opt_out_url") @@ -221,20 +238,21 @@ def score_vendors(vendors: list[dict]) -> list[dict]: else: score += 25 - # Privacy policy URL — relevant for all, but weight lower for necessary - weight = 10 if is_necessary else 15 - max_score += weight - if not v.get("privacy_policy_url"): - flags.append("no_privacy_url") - elif v.get("privacy_ok") is False: - flags.append("broken_privacy_url") - score += weight // 3 - else: - score += weight + # Privacy policy URL — required for external (own = via main DSI) + if privacy_required: + weight = 10 if is_necessary else 15 + max_score += weight + if not v.get("privacy_policy_url"): + flags.append("no_privacy_url") + elif v.get("privacy_ok") is False: + flags.append("broken_privacy_url") + score += weight // 3 + else: + score += weight - # Cookies disclosed (names + expiry) — higher weight for necessary - # (since that's mostly what they offer in lieu of opt-out) - weight = 50 if is_necessary else 15 + # Cookies disclosed (names + expiry) — required for ALL types + # (own processing too: BMW must list its own cookies for the VVT) + weight = 50 if is_own or is_necessary else 15 max_score += weight cookies = v.get("cookies") or [] if cookies: diff --git a/backend-compliance/compliance/services/vendor_classifier.py b/backend-compliance/compliance/services/vendor_classifier.py index ad712211..e01794ff 100644 --- a/backend-compliance/compliance/services/vendor_classifier.py +++ b/backend-compliance/compliance/services/vendor_classifier.py @@ -142,10 +142,10 @@ def classify( # Section ordering + display labels for the VVT email table RECIPIENT_TYPE_SECTIONS = [ - ("INTERNAL", "Eigene Verarbeitung"), - ("GROUP_COMPANY", "Konzernunternehmen (Mutter/Tochter)"), - ("PROCESSOR", "Auftragsverarbeiter (AVV-pflichtig)"), - ("CONTROLLER", "Eigenverantwortliche Dritte / Joint Controller"), + ("INTERNAL", "Eigene Verarbeitungstaetigkeiten — fuer das VVT (Art. 30 DSGVO)"), + ("GROUP_COMPANY", "Konzernunternehmen (Mutter/Tochter) — VVT + ggf. JC/AVV pruefen"), + ("PROCESSOR", "Auftragsverarbeiter — AVV erforderlich (Art. 28 DSGVO)"), + ("CONTROLLER", "Eigenverantwortliche Dritte / Joint Controller — Vereinbarung pruefen (Art. 26 DSGVO)"), ("AUTHORITY", "Behoerden"), ("OTHER", "Sonstige Empfaenger"), ]