From 3f90e4080739e0fd4e5a0f92000b63b0918903b1 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 13 Jun 2026 00:10:41 +0200 Subject: [PATCH] fix(browser-matrix): Tracking-Signal statt Cookie-Rohzahl + Matrix-Schnellpfad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Korrektheit (§ 25 TDDDG): "Cookies vor Consent" ist KEIN Verstoss per se — technisch notwendige Cookies inkl. des Consent-Cookies (speichert die Ablehnung) sind nach Abs. 2 erlaubt. Verstoss ist nur nicht-essentielles TRACKING vor Consent. - browser_cross_finding: Befund haengt jetzt an violations.before_consent (Tracking), nicht an der Cookie-Rohzahl; § 25 Abs. 2-Hinweis im Detail. Regressionstest: Cookies-ohne-Tracking → KEIN Befund. - multi_browser_scanner._extract_dimensions: Score nutzt Tracking-Violations + reject_respected-Verdikt statt Rohzahl (Fallback erhalten). - BrowserBehaviorView: "Cookies vor Consent" nur rot/⚠ bei Tracking, "nach Ablehnen" neutral (Verdikt = reject-Spalte); erklaerende Zeile. Speed: run_consent_test ueberspringt im Matrix-Modus (browser_profile gesetzt) die teuren Phasen C/D-F/G — nur A+B noetig. Verhindert das 504 beim Multi-Engine-Scan (BMW 4 Engines lief sonst in den 338s-Gateway-Timeout). Co-Authored-By: Claude Opus 4.7 --- .../agent/_components/BrowserBehaviorView.tsx | 17 ++++++- .../services/browser_cross_finding.py | 46 +++++++++++-------- .../tests/test_browser_cross_finding.py | 35 ++++++++++---- consent-tester/services/consent_scanner.py | 11 +++++ .../services/multi_browser_scanner.py | 24 ++++++++-- 5 files changed, 99 insertions(+), 34 deletions(-) diff --git a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx index 8f01b129..af6793e3 100644 --- a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx +++ b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx @@ -13,10 +13,12 @@ import React, { useEffect, useState } from 'react' type Finding = { text: string; severity: string; legal_ref?: string; service?: string } type Surface = { has_impressum_link?: boolean; has_dse_link?: boolean; banner_text_issues?: number } +type Violations = { before_consent?: number; after_reject?: number; banner_text?: number } type Summary = { cookies_before_consent?: number; cookies_after_reject?: number reject_respected?: boolean; banner_detected?: boolean; banner_provider?: string banner_screenshot_b64?: string; surface?: Surface; banner_findings?: Finding[] + violations?: Violations } type Row = { profile_id: string; label: string; engine?: string; is_mobile?: boolean @@ -164,6 +166,7 @@ export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) { const s = r.summary const before = s?.cookies_before_consent ?? null const after = s?.cookies_after_reject ?? null + const trackBefore = s?.violations?.before_consent ?? 0 const sld = r.profile_id === sel return ( setSel(r.profile_id)} @@ -178,8 +181,11 @@ export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) { ) : ( <> - 0 ? 'text-red-700 font-semibold' : 'text-green-700'}`}>{before} - 0 ? 'text-amber-700' : 'text-green-700'}`}>{after} + 0 ? 'text-red-700 font-semibold' : 'text-gray-500'}`} + title={trackBefore > 0 ? `${trackBefore} davon Tracking (Verstoß)` : 'kein Tracking vor Consent'}> + {before}{trackBefore > 0 ? ` · ${trackBefore}⚠` : ''} + + {after} {s.reject_respected ? : } @@ -200,6 +206,13 @@ export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) { +

+ „Cookies vor Consent" ist die Rohzahl — technisch notwendige Cookies + (inkl. des Consent-Cookies, das die Ablehnung speichert) sind nach + § 25 Abs. 2 TDDDG erlaubt. Rot/⚠ markiert nur den einwilligungs­pflichtigen + Tracking-Anteil. Das Verdikt zu „Ablehnen" trägt die Spalte rechts. +

+ {selRow && (
diff --git a/backend-compliance/compliance/services/browser_cross_finding.py b/backend-compliance/compliance/services/browser_cross_finding.py index b2418d95..cde91912 100644 --- a/backend-compliance/compliance/services/browser_cross_finding.py +++ b/backend-compliance/compliance/services/browser_cross_finding.py @@ -58,34 +58,44 @@ def build_cross_findings(matrix: dict | None) -> list[dict]: def _s(r: dict) -> dict: return r.get("summary") or {} - pre_yes = [r for r in data if (_s(r).get("cookies_before_consent") or 0) > 0] - pre_no = [r for r in data if (_s(r).get("cookies_before_consent") or 0) == 0] + def _pre_track(r: dict) -> int: + # Nicht-essentielles TRACKING vor Consent (§ 25 Abs. 1 TDDDG) — das + # rechtlich relevante Signal. NICHT die Cookie-Rohzahl: die enthaelt + # technisch notwendige Cookies inkl. des Consent-Cookies selbst (das + # speichern MUSS, dass abgelehnt wurde) → § 25 Abs. 2, einwilligungsfrei. + return (_s(r).get("violations") or {}).get("before_consent") or 0 + + track_yes = [r for r in data if _pre_track(r) > 0] + track_no = [r for r in data if _pre_track(r) == 0] rej_bad = [r for r in data if _s(r).get("reject_respected") is False] rej_ok = [r for r in data if _s(r).get("reject_respected") is True] - # ── Cookies vor der Einwilligung ───────────────────────────────────── - if pre_yes and not pre_no: + # ── Tracking VOR der Einwilligung (nicht: jede Cookie-Rohzahl) ──────── + _ESS = (" Technisch notwendige Cookies inkl. des Consent-Cookies sind " + "ausgenommen (§ 25 Abs. 2 TDDDG).") + if track_yes and not track_no: out.append({ - "title": "Cookies vor der Einwilligung — in allen Browsern", - "detail": "In jeder getesteten Engine werden vor einer aktiven " - "Einwilligung Cookies gesetzt.", + "title": "Tracking vor der Einwilligung — in allen Browsern", + "detail": "In jeder getesteten Engine feuern vor einer aktiven " + "Einwilligung nicht-essentielle Tracker." + _ESS, "severity": "HIGH", - "affected": _labels(pre_yes), - "measure": "Tracking-/Marketing-Cookies erst nach aktiver " - "Einwilligung setzen (§ 25 Abs. 1 TDDDG).", + "affected": _labels(track_yes), + "measure": "Tracking-/Marketing-Skripte erst nach aktiver " + "Einwilligung laden (§ 25 Abs. 1 TDDDG).", }) - elif pre_yes and pre_no: - masked = sorted({_protection_label(r) for r in pre_no if _protection_label(r)}) - hint = (f" Die unauffälligen Engines ({', '.join(_labels(pre_no))}) " - f"unterdrücken die Cookies vermutlich clientseitig " + elif track_yes and track_no: + masked = sorted({_protection_label(r) for r in track_no if _protection_label(r)}) + hint = (f" Die unauffälligen Engines ({', '.join(_labels(track_no))}) " + f"unterdrücken die Tracker vermutlich clientseitig " f"({', '.join(masked)}) — das ist KEIN Compliance-Beleg." if masked else "") out.append({ - "title": "Cookies vor Einwilligung — nur in manchen Browsern", - "detail": f"Cookies vor Consent in {', '.join(_labels(pre_yes))}, " - f"nicht in {', '.join(_labels(pre_no))}.{hint}", + "title": "Tracking vor Einwilligung — nur in manchen Browsern", + "detail": f"Nicht-essentielle Tracker vor Consent in " + f"{', '.join(_labels(track_yes))}, nicht in " + f"{', '.join(_labels(track_no))}.{hint}", "severity": "HIGH", - "affected": _labels(pre_yes), + "affected": _labels(track_yes), "measure": "Server-/skriptseitig auf Consent gaten statt auf den " "Tracking-Schutz einzelner Browser zu vertrauen.", }) diff --git a/backend-compliance/tests/test_browser_cross_finding.py b/backend-compliance/tests/test_browser_cross_finding.py index f160e83c..76331897 100644 --- a/backend-compliance/tests/test_browser_cross_finding.py +++ b/backend-compliance/tests/test_browser_cross_finding.py @@ -8,7 +8,7 @@ from compliance.services.browser_cross_finding import build_cross_findings def _row(pid, label, engine, *, before=0, reject_ok=True, - impressum=True, dse=True, with_summary=True): + impressum=True, dse=True, with_summary=True, track_before=0): if not with_summary: return {"profile_id": pid, "label": label, "engine": engine, "summary": None, "error": "launch failed"} @@ -19,6 +19,9 @@ def _row(pid, label, engine, *, before=0, reject_ok=True, "cookies_after_reject": 0 if reject_ok else 2, "reject_respected": reject_ok, "surface": {"has_impressum_link": impressum, "has_dse_link": dse}, + # before_consent = nicht-essentielles Tracking vor Consent (das + # rechtlich relevante Signal, NICHT die Cookie-Rohzahl `before`). + "violations": {"before_consent": track_before}, }, } @@ -32,22 +35,34 @@ def test_empty_matrix(): assert build_cross_findings({"browser_matrix": []}) == [] -def test_pre_consent_in_all_engines_high(): +def test_tracking_before_consent_in_all_engines_high(): + # Nicht-essentielles Tracking vor Consent (track_before>0) in allen Engines. m = {"browser_matrix": [ - _row("chromium-headed-de", "Chromium", "blink", before=5), - _row("firefox-headed-de", "Firefox", "gecko", before=3), + _row("chromium-headed-de", "Chromium", "blink", before=5, track_before=2), + _row("firefox-headed-de", "Firefox", "gecko", before=3, track_before=1), ]} f = build_cross_findings(m) - hit = [x for x in f if "in allen Browsern" in x["title"]] + hit = [x for x in f if "Tracking vor der Einwilligung — in allen" in x["title"]] assert hit and hit[0]["severity"] == "HIGH" - assert "TDDDG" in hit[0]["measure"] + assert "§ 25 Abs. 2" in hit[0]["detail"] # essentielle Cookies ausgenommen -def test_pre_consent_inconsistent_flags_browser_protection(): - # Chrome (nachgiebig) setzt vor Consent, Safari/WebKit (ITP) nicht. +def test_cookies_before_consent_without_tracking_is_no_finding(): + # KERN-REGRESSION (User-Frage): Cookies vor Consent vorhanden, aber KEIN + # Tracking (z.B. nur das Consent-Cookie selbst) → KEIN Verstoß-Befund. m = {"browser_matrix": [ - _row("chrome-channel-desktop-de", "Chrome", "blink", before=4), - _row("webkit-headed-de", "Safari/WebKit", "webkit", before=0), + _row("chromium-headed-de", "Chromium", "blink", before=5, track_before=0), + _row("firefox-headed-de", "Firefox", "gecko", before=4, track_before=0), + ]} + f = build_cross_findings(m) + assert [x for x in f if "Tracking vor" in x["title"]] == [] + + +def test_tracking_before_consent_inconsistent_flags_browser_protection(): + # Chrome (nachgiebig) lässt Tracker vor Consent, Safari/WebKit (ITP) nicht. + m = {"browser_matrix": [ + _row("chrome-channel-desktop-de", "Chrome", "blink", before=4, track_before=3), + _row("webkit-headed-de", "Safari/WebKit", "webkit", before=0, track_before=0), ]} f = build_cross_findings(m) hit = [x for x in f if "nur in manchen" in x["title"]] diff --git a/consent-tester/services/consent_scanner.py b/consent-tester/services/consent_scanner.py index d5af8b11..aec869bd 100644 --- a/consent-tester/services/consent_scanner.py +++ b/consent-tester/services/consent_scanner.py @@ -344,6 +344,17 @@ async def run_consent_test( await ctx_b.close() + # Matrix-Modus (browser_profile gesetzt): die Per-Engine-Summary + # braucht nur Phase A+B (Cookies/Tracking vor Consent + nach + # Ablehnen, Banner-Text-Checks + Screenshot — alle schon erfasst). + # Die teuren Phasen C (Accept) / D-F (Kategorien) / G (Vendor-Detail) + # ueberspringen → ein Multi-Engine-Scan bleibt im HTTP-Zeitbudget + # (sonst 504). Browser sofort schliessen (Semaphore-Speicher). + if browser_profile: + _apply_edge_case_findings(result, url) + await browser.close() + return result + # ── Phase C: After accepting ───────────────────────── logger.info("Phase C: Accept consent (%s)", banner.provider) ctx_c = await browser.new_context(**_ctx_base) diff --git a/consent-tester/services/multi_browser_scanner.py b/consent-tester/services/multi_browser_scanner.py index 88fe0b3f..6d3c3bcd 100644 --- a/consent-tester/services/multi_browser_scanner.py +++ b/consent-tester/services/multi_browser_scanner.py @@ -55,10 +55,26 @@ def _extract_dimensions(banner_result: dict) -> dict[str, float]: before = phases.get("before_consent") or phases.get("before") or {} after_reject = phases.get("after_reject") or {} bv = (banner_result.get("banner_checks") or {}).get("violations") or [] - pre_cookies = len(before.get("cookies") or []) - rej_cookies = len(after_reject.get("cookies") or []) - pre_consent = max(0.0, 1.0 - min(1.0, pre_cookies / 10.0)) - reject_respect = max(0.0, 1.0 - min(1.0, rej_cookies / 5.0)) + summary = banner_result.get("summary") or {} + viol = summary.get("violations") or {} + # Pre-Consent: das rechtliche Signal ist nicht-essentielles TRACKING vor + # Consent, NICHT die Cookie-Rohzahl (essentielle inkl. Consent-Cookie sind + # nach § 25 Abs. 2 erlaubt). Fallback auf Rohzahl nur ohne Summary. + if "before_consent" in viol: + pre_track = viol.get("before_consent") or 0 + pre_consent = max(0.0, 1.0 - min(1.0, pre_track / 3.0)) + else: + pre_cookies = len(before.get("cookies") or []) + pre_consent = max(0.0, 1.0 - min(1.0, pre_cookies / 10.0)) + # Reject: bevorzugt das reject_respected-Verdikt (kein Verstoß UND kein + # neuer Tracker), sonst after_reject-Tracking, sonst Cookie-Rohzahl. + if summary.get("reject_respected") is not None: + reject_respect = 1.0 if summary.get("reject_respected") else 0.2 + elif "after_reject" in viol: + reject_respect = max(0.0, 1.0 - min(1.0, (viol.get("after_reject") or 0) / 2.0)) + else: + rej_cookies = len(after_reject.get("cookies") or []) + reject_respect = max(0.0, 1.0 - min(1.0, rej_cookies / 5.0)) banner_design = max(0.0, 1.0 - min(1.0, len(bv) / 5.0)) return { "pre_consent": round(pre_consent, 3),