feat(browser-matrix): Cross-Browser-Befunde + Browser-Default-Einordnung (Phase 4)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>; url?: string; scanned_at?: string }
|
||||
type CrossFinding = { title: string; detail?: string; severity: string; affected?: string[]; measure?: string }
|
||||
type Matrix = {
|
||||
browser_matrix?: Row[]; aggregate?: Record<string, unknown>
|
||||
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 }) {
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
|
||||
{/* Cross-Browser-Befunde — der Mehrwert ggü. Einzel-Browser-Scan */}
|
||||
{(matrix.cross_findings?.length ?? 0) > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Cross-Browser-Befunde</h3>
|
||||
{matrix.cross_findings!.map((f, i) => (
|
||||
<div key={i} className="border border-gray-200 rounded-xl p-3 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded uppercase ${sevCls(f.severity)}`}>{f.severity}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{f.title}</span>
|
||||
</div>
|
||||
{f.detail && <p className="text-sm text-gray-600">{f.detail}</p>}
|
||||
{(f.affected?.length ?? 0) > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{f.affected!.map((a, j) => (
|
||||
<span key={j} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{a}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.measure && <p className="text-sm text-gray-700"><span className="text-gray-400">Maßnahme: </span>{f.measure}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500 text-xs">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user