From 85a8a1d545f6a9389aae59eb5122cd7c0ea5d213 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 12 Jun 2026 23:22:57 +0200 Subject: [PATCH] feat(browser-matrix): Cross-Browser-Befunde + Browser-Default-Einordnung (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - browser_cross_finding: deterministische Sicht ueber die Matrix (keine 2. Engine, kein LLM). Findet Inkonsistenzen ZWISCHEN Browsern (Cookies vor Consent / Ablehnen nicht universell respektiert / Banner-Links fehlend) und ordnet ein: Safari-ITP / Brave-Shields / Firefox-ETP maskieren Verstoesse clientseitig → strenge Engine "sauber" ist KEIN Compliance-Beleg, massgeblich sind die nachgiebigen (Chrome/Edge). Coverage-Hinweis fuer nicht verfuegbare Browser. Je Befund Titel/Detail/Severity/affected/Massnahme. - snapshot_check_routes: cross_findings frisch in run + GET (nicht persistiert). - BrowserBehaviorView: "Cross-Browser-Befunde"-Block ueber der Tabelle. - Tests: test_browser_cross_finding (6). Offen (Folge-Task): Borlabs-Consent-Historie-Live-Erkennung (braucht consent-tester-Storage-Scan). Co-Authored-By: Claude Opus 4.7 --- .../agent/_components/BrowserBehaviorView.tsx | 30 +++- .../compliance/api/snapshot_check_routes.py | 10 +- .../services/browser_cross_finding.py | 151 ++++++++++++++++++ .../tests/test_browser_cross_finding.py | 88 ++++++++++ 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 backend-compliance/compliance/services/browser_cross_finding.py create mode 100644 backend-compliance/tests/test_browser_cross_finding.py diff --git a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx index 6ead9190..8f01b129 100644 --- a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx +++ b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx @@ -22,7 +22,11 @@ type Row = { profile_id: string; label: string; engine?: string; is_mobile?: boolean score?: number; verbal?: string; summary?: Summary | null; error?: string } -type Matrix = { browser_matrix?: Row[]; aggregate?: Record; url?: string; scanned_at?: string } +type CrossFinding = { title: string; detail?: string; severity: string; affected?: string[]; measure?: string } +type Matrix = { + browser_matrix?: Row[]; aggregate?: Record + url?: string; scanned_at?: string; cross_findings?: CrossFinding[] +} const sevCls = (s: string) => { const u = (s || '').toUpperCase() @@ -119,6 +123,30 @@ export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) { {error &&
{error}
} + {/* Cross-Browser-Befunde — der Mehrwert ggü. Einzel-Browser-Scan */} + {(matrix.cross_findings?.length ?? 0) > 0 && ( +
+

Cross-Browser-Befunde

+ {matrix.cross_findings!.map((f, i) => ( +
+
+ {f.severity} + {f.title} +
+ {f.detail &&

{f.detail}

} + {(f.affected?.length ?? 0) > 0 && ( +
+ {f.affected!.map((a, j) => ( + {a} + ))} +
+ )} + {f.measure &&

Maßnahme: {f.measure}

} +
+ ))} +
+ )} +
diff --git a/backend-compliance/compliance/api/snapshot_check_routes.py b/backend-compliance/compliance/api/snapshot_check_routes.py index c360ab12..4f5c120b 100644 --- a/backend-compliance/compliance/api/snapshot_check_routes.py +++ b/backend-compliance/compliance/api/snapshot_check_routes.py @@ -191,6 +191,10 @@ async def snapshot_browser_behavior_run( status_code=502, detail=f"consent-tester /scan-matrix fehlgeschlagen: {e}") update_browser_matrix(db, snapshot_id, matrix) + # Cross-Browser-Befunde frisch ableiten (deterministische Sicht, nicht + # persistiert → GET berechnet identisch neu). + from compliance.services.browser_cross_finding import build_cross_findings + matrix["cross_findings"] = build_cross_findings(matrix) return matrix finally: db.close() @@ -202,8 +206,12 @@ async def snapshot_browser_behavior(snapshot_id: str): ist null, solange der On-demand-Lauf noch nie ausgelöst wurde.""" from database import SessionLocal from compliance.services.check_snapshot import load_browser_matrix + from compliance.services.browser_cross_finding import build_cross_findings db = SessionLocal() try: - return {"browser_matrix": load_browser_matrix(db, snapshot_id)} + matrix = load_browser_matrix(db, snapshot_id) + if matrix: + matrix["cross_findings"] = build_cross_findings(matrix) + return {"browser_matrix": matrix} finally: db.close() diff --git a/backend-compliance/compliance/services/browser_cross_finding.py b/backend-compliance/compliance/services/browser_cross_finding.py new file mode 100644 index 00000000..b2418d95 --- /dev/null +++ b/backend-compliance/compliance/services/browser_cross_finding.py @@ -0,0 +1,151 @@ +"""Cross-Browser-Befunde — deterministische Sicht über die Browser-Matrix. + +KEINE zweite Engine, kein LLM: rein eine Auswertung der bereits vom +consent-tester gemessenen Per-Engine-Ergebnisse (siehe +[[feedback_agents_delegate_not_reimplement]]). Findet Inkonsistenzen ZWISCHEN +den Browsern und ordnet sie ein — der zentrale Mehrwert gegenüber einem +Einzel-Browser-Scan: + + * Cookies vor Consent / „Ablehnen“ nicht universell respektiert? + * Browser-Tracking-Schutz (Safari/ITP, Brave/Shields, Firefox/ETP) + maskiert Verstöße clientseitig → ein „sauberes" Ergebnis in einer + strengen Engine ist KEIN Compliance-Beleg; maßgeblich sind die + nachgiebigen Engines (Chrome/Edge/Chromium), in denen tatsächlich + gesetzt wird. +""" + +from __future__ import annotations + +from typing import Any + +# Welcher Browser-Schutzmechanismus greift je Profil — für die Einordnung, +# warum sich Engines unterscheiden (nicht zum Bewerten der Compliance). +_PROTECTION = [ + ("brave", "Brave-Shields"), + ("webkit", "Safari/ITP"), + ("iphone", "Safari/ITP"), + ("safari", "Safari/ITP"), + ("firefox", "Firefox/ETP"), + ("gecko", "Firefox/ETP"), +] + + +def _protection_label(row: dict) -> str: + key = f"{row.get('profile_id', '')} {row.get('engine', '')}".lower() + for needle, label in _PROTECTION: + if needle in key: + return label + return "" # blink/chrome/edge = nachgiebig (kein client-seitiger Schutz) + + +def _labels(rows: list[dict]) -> list[str]: + return [r.get("label") or r.get("profile_id") or "?" for r in rows] + + +def build_cross_findings(matrix: dict | None) -> list[dict]: + """Liefert eine priorisierte Liste von Cross-Browser-Befunden. + + Befund-Form: {title, detail, severity, affected: [labels], measure}. + Nur Engines MIT Messdaten werden verglichen (Fehler-/„nicht verfügbar"- + Zeilen fließen nur als Coverage-Hinweis ein).""" + rows_all = (matrix or {}).get("browser_matrix") or [] + data = [r for r in rows_all if r.get("summary")] + missing = [r for r in rows_all if not r.get("summary")] + out: list[dict] = [] + if not data: + return out + + 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] + 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: + out.append({ + "title": "Cookies vor der Einwilligung — in allen Browsern", + "detail": "In jeder getesteten Engine werden vor einer aktiven " + "Einwilligung Cookies gesetzt.", + "severity": "HIGH", + "affected": _labels(pre_yes), + "measure": "Tracking-/Marketing-Cookies erst nach aktiver " + "Einwilligung setzen (§ 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 " + 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}", + "severity": "HIGH", + "affected": _labels(pre_yes), + "measure": "Server-/skriptseitig auf Consent gaten statt auf den " + "Tracking-Schutz einzelner Browser zu vertrauen.", + }) + + # ── „Ablehnen“ respektiert? ────────────────────────────────────────── + if rej_bad and not rej_ok: + out.append({ + "title": "„Ablehnen“ wird in keinem Browser respektiert", + "detail": "Nach Klick auf „Ablehnen“ verbleiben in jeder Engine " + "Verstöße oder neue Tracker.", + "severity": "HIGH", + "affected": _labels(rej_bad), + "measure": "Reject-Handler muss alle nicht-essentiellen Skripte/" + "Cookies tatsächlich stoppen (Art. 7 Abs. 3 DSGVO).", + }) + elif rej_bad and rej_ok: + masked = sorted({_protection_label(r) for r in rej_ok if _protection_label(r)}) + hint = (f" Dass {', '.join(_labels(rej_ok))} sauber wirken, liegt " + f"vermutlich am Browser-Schutz ({', '.join(masked)}), nicht am " + f"Banner." if masked else "") + out.append({ + "title": "„Ablehnen“ wird nicht in allen Browsern respektiert", + "detail": f"Verstoß nach Ablehnen in {', '.join(_labels(rej_bad))}; " + f"sauber in {', '.join(_labels(rej_ok))}.{hint}", + "severity": "HIGH", + "affected": _labels(rej_bad), + "measure": "Reject-Handler universell wirksam machen — unabhängig " + "vom Tracking-Schutz des Browsers.", + }) + + # ── Oberfläche (Banner-Links) durchgängig fehlend ──────────────────── + if all(not _s(r).get("surface", {}).get("has_impressum_link") for r in data): + out.append({ + "title": "Impressum-Link im Banner fehlt (alle Browser)", + "detail": "In keiner Engine ist aus dem Banner ein Impressum " + "erreichbar.", + "severity": "MEDIUM", "affected": _labels(data), + "measure": "Impressum-Link im Banner ergänzen (§ 5 DDG).", + }) + if all(not _s(r).get("surface", {}).get("has_dse_link") for r in data): + out.append({ + "title": "Datenschutz-Link im Banner fehlt (alle Browser)", + "detail": "In keiner Engine ist aus dem Banner die " + "Datenschutzerklärung erreichbar.", + "severity": "MEDIUM", "affected": _labels(data), + "measure": "Link zur Datenschutzerklärung im Banner ergänzen " + "(Art. 13 DSGVO).", + }) + + # ── Coverage-Hinweis: nicht getestete Browser ──────────────────────── + if missing: + out.append({ + "title": "Nicht alle Browser getestet", + "detail": f"{len(missing)} Browser nicht verfügbar " + f"({', '.join(_labels(missing))}). Echtes Brave/Chrome/" + f"Edge laufen nur auf dem amd64-Server, nicht in der " + f"arm64-Dev-Umgebung.", + "severity": "LOW", "affected": _labels(missing), + "measure": "Für vollständige Abdeckung auf dem Produktiv-Server " + "(amd64) testen.", + }) + + return out diff --git a/backend-compliance/tests/test_browser_cross_finding.py b/backend-compliance/tests/test_browser_cross_finding.py new file mode 100644 index 00000000..f160e83c --- /dev/null +++ b/backend-compliance/tests/test_browser_cross_finding.py @@ -0,0 +1,88 @@ +"""Cross-Browser-Befunde (deterministische Matrix-Sicht, Phase 4). + +Sichert die Inkonsistenz-Erkennung ZWISCHEN Engines + die Einordnung, dass +Browser-Tracking-Schutz (Safari/ITP, Brave/Shields, Firefox/ETP) Verstöße +clientseitig maskiert (kein Compliance-Beleg).""" + +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): + if not with_summary: + return {"profile_id": pid, "label": label, "engine": engine, + "summary": None, "error": "launch failed"} + return { + "profile_id": pid, "label": label, "engine": engine, + "summary": { + "cookies_before_consent": before, + "cookies_after_reject": 0 if reject_ok else 2, + "reject_respected": reject_ok, + "surface": {"has_impressum_link": impressum, "has_dse_link": dse}, + }, + } + + +def _titles(findings): + return " | ".join(f["title"] for f in findings) + + +def test_empty_matrix(): + assert build_cross_findings(None) == [] + assert build_cross_findings({"browser_matrix": []}) == [] + + +def test_pre_consent_in_all_engines_high(): + m = {"browser_matrix": [ + _row("chromium-headed-de", "Chromium", "blink", before=5), + _row("firefox-headed-de", "Firefox", "gecko", before=3), + ]} + f = build_cross_findings(m) + hit = [x for x in f if "in allen Browsern" in x["title"]] + assert hit and hit[0]["severity"] == "HIGH" + assert "TDDDG" in hit[0]["measure"] + + +def test_pre_consent_inconsistent_flags_browser_protection(): + # Chrome (nachgiebig) setzt vor Consent, Safari/WebKit (ITP) nicht. + m = {"browser_matrix": [ + _row("chrome-channel-desktop-de", "Chrome", "blink", before=4), + _row("webkit-headed-de", "Safari/WebKit", "webkit", before=0), + ]} + f = build_cross_findings(m) + hit = [x for x in f if "nur in manchen" in x["title"]] + assert hit and hit[0]["severity"] == "HIGH" + # Einordnung: ITP maskiert clientseitig → kein Compliance-Beleg. + assert "ITP" in hit[0]["detail"] + assert "Chrome" in hit[0]["affected"] + + +def test_reject_inconsistent_high(): + m = {"browser_matrix": [ + _row("chrome-channel-desktop-de", "Chrome", "blink", reject_ok=False), + _row("brave-default-de", "Brave", "blink", reject_ok=True), + ]} + f = build_cross_findings(m) + hit = [x for x in f if "nicht in allen Browsern respektiert" in x["title"]] + assert hit and hit[0]["severity"] == "HIGH" + assert "Chrome" in hit[0]["affected"] + + +def test_missing_engines_coverage_hint(): + m = {"browser_matrix": [ + _row("chromium-headed-de", "Chromium", "blink"), + _row("brave-default-de", "Brave", "blink", with_summary=False), + ]} + f = build_cross_findings(m) + hit = [x for x in f if "Nicht alle Browser getestet" in x["title"]] + assert hit and hit[0]["severity"] == "LOW" + assert "Brave" in hit[0]["affected"] + + +def test_clean_matrix_no_violations(): + m = {"browser_matrix": [ + _row("chromium-headed-de", "Chromium", "blink"), + _row("firefox-headed-de", "Firefox", "gecko"), + ]} + # Alles sauber → keine Verstoß-Befunde (Impressum/DSE vorhanden). + assert build_cross_findings(m) == []