feat(b14): widersprüchliche Speicherdauer im selben Doc (GT TH-RETENTION-001)

Erkennt: in derselben DSE / Cookie-Richtlinie nennt der Anbieter für
DIESELBE Datenkategorie mehrere unterschiedliche Speicherdauern.

GT-Anker (Elli): Logfiles "7 Tage" + "30 Tage" im selben DSE → eine
Angabe ist falsch oder veraltet.

Heuristik:
  - Satz-Boundary-Scope (kein ±N-Zeichen-Fenster) verhindert
    Cross-Category-Leakage
  - Pro Satz: Kategorie-Anchor + Retention-Werte beide drin
  - Tag-Cluster mit ±20 %-Toleranz: "30 Tage" und "1 Monat" =
    1 Cluster; "7 Tage" und "30 Tage" = 2 Cluster → Finding

Kategorien (Phase 1):
  - logfile, contact_form, application, newsletter, invoice,
    session_cookie

Severity: MEDIUM (DSGVO Art. 5 Abs. 1 lit. a + Art. 13 Abs. 2 lit. a).

Tests: 11/11 grün (Cluster-Logik 5, Check-Pfade 6, inkl. Cross-
Category-Leakage-Regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-07 00:12:00 +02:00
parent 8b9cad88ae
commit 6aad774fc1
5 changed files with 347 additions and 0 deletions
@@ -0,0 +1,69 @@
"""B14 wiring — Conflicting-Retention-Detector.
Hängt sich an `state["extra_findings"]` an und rendert einen V2-Block
(`retention_conflict_html`).
"""
from __future__ import annotations
import html
import logging
from compliance.services.retention_conflict_check import (
check_retention_conflicts,
)
logger = logging.getLogger(__name__)
def run_b14(state: dict) -> None:
new = check_retention_conflicts(state)
if not new:
return
extras = state.get("extra_findings") or []
extras.extend(new)
state["extra_findings"] = extras
state["retention_conflict_html"] = _render(new)
logger.info("B14 retention-conflict: %d finding(s)", len(new))
def _render(findings: list[dict]) -> str:
cards = []
for f in findings:
sev = (f.get("severity") or "").upper()
color = "#f59e0b" if sev == "MEDIUM" else "#dc2626"
vals = f.get("values_days") or []
vals_html = ""
if vals:
vals_html = (
"<div style='font-size:12px;color:#475569;margin-top:6px;'>"
f"<em>Werte (Tage): {html.escape(', '.join(str(v) for v in vals))}</em>"
"</div>"
)
cards.append(
f"<div style='margin:12px 0;padding:14px;background:#fff;"
f"border-left:3px solid {color};border-radius:4px;'>"
f"<div style='font-weight:600;color:{color};font-size:14px;'>"
f"{sev} · {html.escape(f.get('check_id') or '')}</div>"
f"<div style='font-size:14px;margin-top:4px;'>"
f"<strong>{html.escape(f.get('title') or '')}</strong></div>"
f"<div style='font-size:12px;color:#64748b;margin-top:2px;'>"
f"{html.escape(f.get('norm') or '')}</div>"
f"{vals_html}"
f"<div style='font-size:12px;color:#475569;margin-top:6px;'>"
f"<em>{html.escape(f.get('evidence') or '')}</em></div>"
f"<div style='font-size:13px;margin-top:8px;background:#dcfce7;"
f"padding:8px 10px;border-radius:4px;'>"
f"<strong>→ Empfehlung:</strong> "
f"{html.escape(f.get('action') or '')}</div>"
"</div>"
)
return (
"<div style='margin:24px 0;padding:16px;border-left:4px solid #f59e0b;"
"background:#fffbeb;border-radius:4px;'>"
"<h2 style='margin:0 0 8px;color:#92400e;font-size:16px;'>"
"⏱️ Widersprüchliche Speicherdauer (Doc-intern)"
"</h2>"
+ "".join(cards) +
"</div>"
)
@@ -24,6 +24,7 @@ from ._b6b7b8_wiring import run_b6b7b8
from ._b9b10_wiring import run_b9b10
from ._b12_wiring import run_b12
from ._b13_wiring import run_b13
from ._b14_wiring import run_b14
from ._constants import _compliance_check_jobs
from ._phase_a_resolve import run_phase_a
from ._phase_b_profile_check import run_phase_b
@@ -72,6 +73,7 @@ async def run_compliance_check(check_id: str, req) -> None:
run_b9b10(state) # Multi-Entity-Impressum + Drittland-Mechanismus
run_b12(state) # Chatbot-Cookie-Klassifikation (B11 ist in B9B10)
run_b13(state) # Widerrufsbelehrung-Reachability (B2C-Pflicht)
run_b14(state) # Widersprüchliche Speicherdauer im selben Doc
# Phase D-3 top/mid/bot: Step 5 HTML blocks
await run_phase_d3_top(state)
await run_phase_d3_mid(state)