diff --git a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx
index cc08b510..fc2ff860 100644
--- a/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx
+++ b/admin-compliance/app/sdk/agent/_components/BrowserBehaviorView.tsx
@@ -14,11 +14,15 @@ 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 ConsentHistory = {
+ provider?: string; history_capable?: boolean; withdraw_ui?: boolean
+ versioned_consent?: boolean; stored?: boolean
+}
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
+ violations?: Violations; consent_history?: ConsentHistory
}
type Row = {
profile_id: string; label: string; engine?: string; is_mobile?: boolean
@@ -227,6 +231,14 @@ export function BrowserBehaviorView({ snapshotId }: { snapshotId: string }) {
) : (
Kein Banner-Screenshot erfasst.
)}
+ {selRow.summary?.consent_history && (
+
+ Einwilligungs-Historie:{' '}
+ {selRow.summary.consent_history.provider || 'kein bekanntes CMP erkannt'}
+ {selRow.summary.consent_history.history_capable ? ' · versioniert (nachvollziehbar)' : ''}
+ {selRow.summary.consent_history.withdraw_ui ? ' · Widerruf-Widget vorhanden' : ' · kein Widerruf-Widget erkannt'}
+
+ )}
{(selRow.summary?.banner_findings?.length ?? 0) > 0 ? (
{selRow.summary!.banner_findings!.map((f, i) => (
diff --git a/backend-compliance/compliance/services/browser_cross_finding.py b/backend-compliance/compliance/services/browser_cross_finding.py
index 08a11a93..75963390 100644
--- a/backend-compliance/compliance/services/browser_cross_finding.py
+++ b/backend-compliance/compliance/services/browser_cross_finding.py
@@ -131,6 +131,38 @@ def build_cross_findings(matrix: dict | None) -> list[dict]:
# bewusst: ist der Footer-Link trotz Banner erreichbar → LOW/Best Practice
# statt Verstoss. Doppel-/Falsch-Flag hier vermieden.
+ # ── Consent-Historie / Widerruf (Best Practice, #62) ─────────────────
+ ch_rows = [(_s(r).get("consent_history") or {}) for r in data]
+ any_history = any(c.get("history_capable") for c in ch_rows)
+ any_withdraw = any(c.get("withdraw_ui") for c in ch_rows)
+ provider = next((c.get("provider") for c in ch_rows if c.get("provider")), "")
+ if any_history or any_withdraw:
+ bits = []
+ if provider:
+ bits.append(f"Anbieter: {provider}")
+ if any_history:
+ bits.append("versionierte Einwilligung (nachvollziehbar)")
+ if any_withdraw:
+ bits.append("Widerruf-/Einstellungs-Widget vorhanden")
+ out.append({
+ "title": "Einwilligungs-Historie / Widerruf vorhanden",
+ "detail": "Positiv: " + ", ".join(bits) + ". Nutzer können "
+ "nachvollziehen bzw. ändern, welcher Version sie zugestimmt "
+ "haben.",
+ "severity": "LOW", "affected": _labels(data),
+ "measure": "Beibehalten.",
+ })
+ else:
+ out.append({
+ "title": "Keine sichtbare Einwilligungs-Historie",
+ "detail": "Weder eine versionierte Consent-Historie noch ein "
+ "dauerhaftes Widerruf-Widget erkannt.",
+ "severity": "LOW", "affected": _labels(data),
+ "measure": "Best Practice: Consent-Historie + jederzeit erreichbares "
+ "Widerruf-/Einstellungs-Widget anbieten (Borlabs-Stil) — "
+ "Nutzer sehen, wann sie welcher Version zugestimmt haben.",
+ })
+
# ── Coverage-Hinweis: nicht getestete Browser ────────────────────────
if missing:
out.append({
diff --git a/backend-compliance/tests/test_browser_cross_finding.py b/backend-compliance/tests/test_browser_cross_finding.py
index 76331897..ef00bb09 100644
--- a/backend-compliance/tests/test_browser_cross_finding.py
+++ b/backend-compliance/tests/test_browser_cross_finding.py
@@ -8,7 +8,8 @@ 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, track_before=0):
+ impressum=True, dse=True, with_summary=True, track_before=0,
+ consent_history=None):
if not with_summary:
return {"profile_id": pid, "label": label, "engine": engine,
"summary": None, "error": "launch failed"}
@@ -22,6 +23,7 @@ def _row(pid, label, engine, *, before=0, reject_ok=True,
# before_consent = nicht-essentielles Tracking vor Consent (das
# rechtlich relevante Signal, NICHT die Cookie-Rohzahl `before`).
"violations": {"before_consent": track_before},
+ "consent_history": consent_history or {},
},
}
@@ -99,5 +101,21 @@ def test_clean_matrix_no_violations():
_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) == []
+ f = build_cross_findings(m)
+ # Keine HIGH/MEDIUM-Verstöße; nur der Consent-Historie-Best-Practice-Hinweis (LOW).
+ assert [x for x in f if x["severity"] in ("HIGH", "MEDIUM")] == []
+
+
+def test_consent_history_present_is_positive():
+ ch = {"provider": "Borlabs", "history_capable": True, "withdraw_ui": True}
+ m = {"browser_matrix": [
+ _row("chromium-headed-de", "Chromium", "blink", consent_history=ch),
+ ]}
+ hit = [x for x in build_cross_findings(m) if "Historie / Widerruf vorhanden" in x["title"]]
+ assert hit and "Borlabs" in hit[0]["detail"]
+
+
+def test_consent_history_absent_is_best_practice_low():
+ m = {"browser_matrix": [_row("chromium-headed-de", "Chromium", "blink")]}
+ hit = [x for x in build_cross_findings(m) if "Keine sichtbare Einwilligungs-Historie" in x["title"]]
+ assert hit and hit[0]["severity"] == "LOW"
diff --git a/consent-tester/services/consent_history.py b/consent-tester/services/consent_history.py
new file mode 100644
index 00000000..ca30914f
--- /dev/null
+++ b/consent-tester/services/consent_history.py
@@ -0,0 +1,95 @@
+"""Consent-Historie-/Widerruf-Erkennung (Borlabs-Stil) während des Scans.
+
+Erkennt, ob die Site ihre Einwilligung versioniert speichert (Borlabs hält die
+zugestimmte Version + Zeitstempel → Nutzer kann nachvollziehen, welcher Version
+er wann zugestimmt hat) und ob ein dauerhaftes Widerruf-/„Cookie-Einstellungen"-
+Widget angeboten wird. Reine Klassifikation (`classify_provider`) ist ohne
+Browser unit-testbar; `detect_consent_history` kapselt das Playwright-IO.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+# Signatur-Fragmente in Storage-Keys/Cookie-Namen → CMP-Anbieter.
+_PROVIDERS = [
+ ("Borlabs", ["borlabs-cookie", "borlabscookie", "borlabs"]),
+ ("Usercentrics", ["uc_settings", "uc_user_interaction", "usercentrics"]),
+ ("OneTrust", ["optanonconsent", "optanonalertbox", "onetrust"]),
+ ("Cookiebot", ["cookieconsent", "cookiebot"]),
+ ("Complianz", ["cmplz_", "complianz"]),
+ ("Cookie-Script", ["cookiescriptconsent"]),
+]
+
+# Wer trägt von Haus aus eine versionierte Consent-Historie (Capability).
+_HISTORY_CAPABLE = {"Borlabs", "Usercentrics", "OneTrust", "Cookiebot"}
+
+# Selektoren für ein dauerhaftes Widerruf-/Einstellungs-Widget.
+_WITHDRAW_SELECTOR = (
+ 'a:has-text("Cookie-Einstellungen"), button:has-text("Cookie-Einstellungen"), '
+ 'a:has-text("Einwilligung"), button:has-text("Einwilligung"), '
+ 'a:has-text("Cookie Settings"), button:has-text("Cookie Settings"), '
+ 'a:has-text("Consent"), button:has-text("Consent"), '
+ '[id*="borlabs-cookie"], [class*="borlabs-cookie"], #BorlabsCookieBox, '
+ '[class*="cookie-preference"], [class*="cmplz-manage"]'
+)
+
+
+def classify_provider(names: list[str]) -> str:
+ """Storage-Keys + Cookie-Namen → CMP-Anbieter ('' wenn unbekannt). Pur."""
+ blob = " ".join(n.lower() for n in names if n)
+ for provider, sigs in _PROVIDERS:
+ if any(s in blob for s in sigs):
+ return provider
+ return ""
+
+
+def _is_versioned(provider: str, stored_value: Optional[str]) -> bool:
+ """True, wenn der gespeicherte Consent eine Version/Consent-Liste trägt
+ (Indiz für nachvollziehbare Historie)."""
+ if not stored_value:
+ return provider in _HISTORY_CAPABLE # Capability auch ohne Wert
+ low = stored_value.lower()
+ return any(t in low for t in ("version", "consents", "timestamp", "consentid"))
+
+
+async def detect_consent_history(page: Any) -> dict:
+ """Liest Storage/Cookies + DOM und liefert:
+ {provider, stored, versioned_consent, history_capable, withdraw_ui}."""
+ keys: list[str] = []
+ try:
+ keys = await page.evaluate("() => Object.keys(window.localStorage || {})")
+ except Exception:
+ keys = []
+ cookie_names: list[str] = []
+ try:
+ cookie_names = [c.get("name", "") for c in await page.context.cookies()]
+ except Exception:
+ cookie_names = []
+
+ provider = classify_provider(list(keys) + cookie_names)
+
+ stored_value = None
+ if provider == "Borlabs":
+ try:
+ stored_value = await page.evaluate(
+ "() => localStorage.getItem('borlabs-cookie') || "
+ "localStorage.getItem('BorlabsCookie')")
+ except Exception:
+ stored_value = None
+
+ versioned = _is_versioned(provider, stored_value)
+
+ withdraw = False
+ try:
+ withdraw = await page.locator(_WITHDRAW_SELECTOR).count() > 0
+ except Exception:
+ withdraw = False
+
+ return {
+ "provider": provider,
+ "stored": bool(provider),
+ "versioned_consent": versioned,
+ "history_capable": versioned or provider in _HISTORY_CAPABLE,
+ "withdraw_ui": withdraw,
+ }
diff --git a/consent-tester/services/consent_scanner.py b/consent-tester/services/consent_scanner.py
index aec869bd..ee46920e 100644
--- a/consent-tester/services/consent_scanner.py
+++ b/consent-tester/services/consent_scanner.py
@@ -81,6 +81,9 @@ class ConsentTestResult:
# Backend embedded das als
in der Mail — visueller Beweis
# "so sah das Banner zum Audit-Zeitpunkt aus".
banner_screenshot_b64: str = ""
+ # #62: Consent-Historie/Widerruf (Borlabs-Stil) — Provider, versionierter
+ # Consent (historie-fähig), dauerhaftes Widerruf-Widget.
+ consent_history: dict = field(default_factory=dict)
def _apply_edge_case_findings(result, url: str = "") -> None:
@@ -274,6 +277,13 @@ async def run_consent_test(
except Exception as _se:
logger.warning("P85: banner screenshot failed: %s", _se)
+ # #62: Consent-Historie/Widerruf (Borlabs-Stil) erkennen.
+ try:
+ from services.consent_history import detect_consent_history
+ result.consent_history = await detect_consent_history(page_a)
+ except Exception as _che:
+ logger.warning("consent-history detection failed: %s", _che)
+
await ctx_a.close()
if not banner.detected:
diff --git a/consent-tester/services/scan_matrix_summary.py b/consent-tester/services/scan_matrix_summary.py
index ed5d25f1..e58d54ea 100644
--- a/consent-tester/services/scan_matrix_summary.py
+++ b/consent-tester/services/scan_matrix_summary.py
@@ -63,6 +63,8 @@ def matrix_scan_dict(result: Any) -> dict:
getattr(result, "banner_has_dse_link", False)),
"banner_text_issues": len(banner_text_violations),
},
+ # #62: Consent-Historie/Widerruf (Borlabs-Stil).
+ "consent_history": getattr(result, "consent_history", {}) or {},
# Oberflächen-Befunde je Engine (die 20 Banner-Checks: Button-Prominenz,
# Toggle-Vorauswahl, Einleitungstext/Links …) — Text + Severity +
# Norm-Bezug. Aggregierte Maßnahmen folgen im Cross-Finding.
diff --git a/consent-tester/tests/test_consent_history.py b/consent-tester/tests/test_consent_history.py
new file mode 100644
index 00000000..8090f471
--- /dev/null
+++ b/consent-tester/tests/test_consent_history.py
@@ -0,0 +1,35 @@
+"""Consent-Historie-Erkennung (#62) — pure Klassifikation."""
+
+from services.consent_history import classify_provider, _is_versioned
+
+
+def test_classify_borlabs():
+ assert classify_provider(["borlabs-cookie", "PHPSESSID"]) == "Borlabs"
+
+
+def test_classify_onetrust():
+ assert classify_provider(["OptanonConsent", "foo"]) == "OneTrust"
+
+
+def test_classify_cookiebot():
+ assert classify_provider(["CookieConsent"]) == "Cookiebot"
+
+
+def test_classify_unknown_empty():
+ assert classify_provider(["sessionid", "csrftoken"]) == ""
+ assert classify_provider([]) == ""
+
+
+def test_versioned_from_stored_value():
+ assert _is_versioned("Borlabs", '{"version":3,"consents":{}}') is True
+ assert _is_versioned("Borlabs", '{"timestamp":123}') is True
+
+
+def test_versioned_capability_without_value():
+ # Borlabs ist historie-fähig auch ohne ausgelesenen Wert.
+ assert _is_versioned("Borlabs", None) is True
+
+
+def test_not_versioned_unknown_provider():
+ assert _is_versioned("", None) is False
+ assert _is_versioned("", "irgendwas") is False