From 97e39579d550a4d4aa560c883a4fe5d919ab2c44 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 11 Jun 2026 20:45:18 +0200 Subject: [PATCH] feat(cookie+routing): Storage-Typ-Filter + legal_notice capture-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3 Storage-Filter: cookie-check exponiert per-Cookie-Speichertyp (storage_inventory.per_cookie); CookieResultView bekommt Filter-Chips (Cookie/Local Storage/Framework …) + eine Speicher-Spalte, Anbieter ohne passenden Treffer werden ausgeblendet, KPI zeigt gefilterte Zahl. A-Routing: legal_notice ist jetzt ein kanonischer Doc-Type. Eigene Discovery-Regel (legal-disclaimer/rechtlicher-hinweis) VOR impressum → die Disclaimer-Seite wird nicht mehr als Impressum substituiert (Ursache, dass die Cross-Doc-Reconciliation nie zündete). capture-only: als doc_entry für B persistiert, aber nicht einzeln gescort (keine 0%-Noise, da ohne eigene Checkliste). Im Scan-Form als Option auswählbar. Co-Authored-By: Claude Opus 4.7 --- .../agent/_components/CookieResultView.tsx | 93 +++++++++++++++++-- .../__tests__/CookieResultView.test.tsx | 12 +++ .../sdk/agent/_components/_document_types.ts | 1 + .../sdk/agent/snapshots/[snapshotId]/page.tsx | 2 +- .../compliance/api/agent_check/_constants.py | 18 +++- .../api/agent_check/_phase_b_profile_check.py | 11 +++ .../services/cookie_storage_inventory.py | 6 ++ .../tests/test_cookie_storage_inventory.py | 5 + 8 files changed, 137 insertions(+), 11 deletions(-) diff --git a/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx b/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx index 15c45ef1..c5ae204b 100644 --- a/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx +++ b/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx @@ -42,6 +42,26 @@ interface Snapshot { // name_lower → tatsächliche Kategorie laut Library (aus /cookie-check). export type LibCategories = Record +// name_lower → Speichertyp (cookie | local_storage | framework_storage | …). +export type StorageTypes = Record + +const STORAGE_LABEL: Record = { + cookie: 'Cookie', local_storage: 'Local Storage', + session_storage: 'Session Storage', indexeddb: 'IndexedDB', + framework_storage: 'Framework', +} +const STORAGE_COLOR: Record = { + cookie: 'bg-gray-100 text-gray-500', + local_storage: 'bg-purple-100 text-purple-700', + session_storage: 'bg-indigo-100 text-indigo-700', + indexeddb: 'bg-cyan-100 text-cyan-700', + framework_storage: 'bg-orange-100 text-orange-700', +} +const STORAGE_ORDER = ['cookie', 'local_storage', 'session_storage', 'indexeddb', 'framework_storage'] + +function storageOf(name: string, st?: StorageTypes): string { + return st?.[(name || '').toLowerCase()] || 'cookie' +} const ROLE_LABEL: Record = { unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token', @@ -116,9 +136,14 @@ function Tile({ label, value, tone }: { label: string; value: React.ReactNode; t ) } -function VendorRow({ v, lib }: { v: SnapshotVendor; lib?: LibCategories }) { +function VendorRow( + { v, lib, st, sf }: + { v: SnapshotVendor; lib?: LibCategories; st?: StorageTypes; sf: string }, +) { const [open, setOpen] = useState(false) - const cookies = v.cookies || [] + const cookies = sf + ? (v.cookies || []).filter(c => storageOf(c.name, st) === sf) + : (v.cookies || []) const cat = (v.category || '').toLowerCase() const declaredCanon = canonCat(v.category) const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase()) @@ -151,6 +176,7 @@ function VendorRow({ v, lib }: { v: SnapshotVendor; lib?: LibCategories }) { Cookie + Speicher Rolle Zweck Laufzeit @@ -172,6 +198,16 @@ function VendorRow({ v, lib }: { v: SnapshotVendor; lib?: LibCategories }) { )} + + {(() => { + const t = storageOf(c.name, st) + return t !== 'cookie' ? ( + + {STORAGE_LABEL[t] || t} + + ) : Cookie + })()} + {c.functional_role && c.functional_role !== 'unknown' ? (ROLE_LABEL[c.functional_role] || c.functional_role) @@ -195,11 +231,26 @@ function VendorRow({ v, lib }: { v: SnapshotVendor; lib?: LibCategories }) { } export function CookieResultView( - { snapshot, cookieCategories }: - { snapshot: Snapshot; cookieCategories?: LibCategories }, + { snapshot, cookieCategories, storageTypes }: + { snapshot: Snapshot; cookieCategories?: LibCategories; storageTypes?: StorageTypes }, ) { const vendors = snapshot.cmp_vendors || [] const [viewMode, setViewMode] = useState<'role' | 'category'>('role') + const [storageFilter, setStorageFilter] = useState('') + + // Speichertyp-Verteilung über alle Cookies (für die Filter-Chips + Zähler). + const storagePresent = useMemo(() => { + const counts: Record = {} + for (const v of vendors) + for (const c of v.cookies || []) { + const t = storageOf(c.name, storageTypes) + counts[t] = (counts[t] || 0) + 1 + } + return counts + }, [vendors, storageTypes]) + + const matchesSF = (v: SnapshotVendor) => + !storageFilter || (v.cookies || []).some(c => storageOf(c.name, storageTypes) === storageFilter) const stats = useMemo(() => { const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0) @@ -220,7 +271,7 @@ export function CookieResultView( (a.compliance_score ?? 100) - (b.compliance_score ?? 100) if (viewMode === 'category') { return CATEGORY_GROUPS - .map(g => ({ ...g, vendors: vendors.filter(v => canonCat(v.category) === g.key).sort(sortByScore) })) + .map(g => ({ ...g, vendors: vendors.filter(v => canonCat(v.category) === g.key).filter(matchesSF).sort(sortByScore) })) .filter(g => g.vendors.length > 0) } return GROUPS @@ -228,10 +279,11 @@ export function CookieResultView( ...g, vendors: vendors .filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key) + .filter(matchesSF) .sort(sortByScore), })) .filter(g => g.vendors.length > 0) - }, [vendors, viewMode]) + }, [vendors, viewMode, storageFilter, storageTypes]) const toggleBtn = (mode: 'role' | 'category', label: string) => ( + {STORAGE_ORDER.filter(t => storagePresent[t]).map(t => ( + + ))} + + )} + {viewMode === 'category' && (

Banner-Kategorie wie im Consent-Tool deklariert. Badge{' '} @@ -283,7 +360,7 @@ export function CookieResultView( {g.label} ({g.vendors.length})

- {g.vendors.map((v, i) => )} + {g.vendors.map((v, i) => )}
))} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx index 0b9afd33..5b46581f 100644 --- a/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx +++ b/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx @@ -66,4 +66,16 @@ describe('CookieResultView', () => { fireEvent.click(screen.getByText('Salesforce')) expect(screen.getByText(/sollte: Marketing/)).toBeInTheDocument() }) + + it('filtert nach Speichertyp (Framework vs. Cookie)', () => { + // LSKey-c$Policy ist Framework-Storage, alle anderen echte Cookies. + render() + const chip = screen.getByText(/Framework \(1\)/) + expect(chip).toBeInTheDocument() // Chip-Leiste erscheint (Nicht-Cookie vorhanden) + fireEvent.click(chip) + // Nur Salesforce (hat das Framework-Objekt) bleibt sichtbar. + expect(screen.getByText('Salesforce')).toBeInTheDocument() + expect(screen.queryByText('BMW AG — eShop')).not.toBeInTheDocument() + expect(screen.queryByText('Meta / Facebook')).not.toBeInTheDocument() + }) }) diff --git a/admin-compliance/app/sdk/agent/_components/_document_types.ts b/admin-compliance/app/sdk/agent/_components/_document_types.ts index 6d4570a4..e3d2770f 100644 --- a/admin-compliance/app/sdk/agent/_components/_document_types.ts +++ b/admin-compliance/app/sdk/agent/_components/_document_types.ts @@ -16,6 +16,7 @@ export const DOCUMENT_TYPES = [ { id: 'widerruf', label: 'Widerrufsbelehrung', required: false }, { id: 'dsb', label: 'DSB-Kontakt', required: false }, { id: 'news', label: 'Blog/Newsroom (für § 18 MStV)', required: false }, + { id: 'legal_notice', label: 'Rechtlicher Hinweis / Disclaimer', required: false }, ] as const export type DocTypeId = typeof DOCUMENT_TYPES[number]['id'] diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx index b4b94be7..e617c84e 100644 --- a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -94,7 +94,7 @@ export default function SnapshotDetail( {tab === 'cookie' && hasCookies && (
- +
)} diff --git a/backend-compliance/compliance/api/agent_check/_constants.py b/backend-compliance/compliance/api/agent_check/_constants.py index 628f45de..6dca22ca 100644 --- a/backend-compliance/compliance/api/agent_check/_constants.py +++ b/backend-compliance/compliance/api/agent_check/_constants.py @@ -31,10 +31,18 @@ _compliance_check_jobs: dict[str, dict] = {} # a separate page. We check 'DSB benannt' as a sub-check of the DSE. _ALL_DOC_TYPES = [ "dse", "impressum", "social_media", "cookie", - "agb", "nutzungsbedingungen", "widerruf", + "agb", "nutzungsbedingungen", "widerruf", "legal_notice", ] +# Capture-only doc types: erfasst + als doc_entry persistiert (für die +# Cross-Doc-Reconciliation B), aber NICHT einzeln gescort. Sie haben keine +# eigene Checkliste/MCs → _check_single würde nur eine irreführende 0%-Zeile +# erzeugen. 'legal_notice' (Footer-„Rechtlicher Hinweis"/Disclaimer) trägt oft +# VSBG/ODR-Aussagen, die Impressum-Pflichten erfüllen → wertvoll für B. +_CAPTURE_ONLY = {"legal_notice"} + + # Human-readable labels per doc_type. Used in the report + emails. _DOC_TYPE_LABELS = { "dse": "Datenschutzerklaerung", @@ -77,8 +85,14 @@ _DISCOVERY_RULES: list[tuple[str, tuple[str, ...]]] = [ "allgemeine-nutzungsbedingungen")), ("dsb", ("datenschutzbeauftragt", "data-protection-officer", "dpo-contact", "/dsb")), + # A: 'legal-disclaimer' (Footer-„Rechtlicher Hinweis") VOR impressum, damit + # die Disclaimer-Seite NICHT mehr als Impressum substituiert wird (war die + # Ursache, dass die Cross-Doc-Reconciliation nie zündete). + ("legal_notice", ("legal-disclaimer", "legal-disclaimer-pool", + "rechtlicher-hinweis", "rechtliche-hinweise", + "haftungsausschluss")), ("impressum", ("impressum", "imprint", "legal-notice", "site-notice", - "anbieterkennzeichnung", "legal-disclaimer-pool")), + "anbieterkennzeichnung")), ("dse", ("data-privacy", "datenschutz", "data-protection", "privacy-policy", "privacy-notice", "dsgvo", "data_privacy", "datenschutzinformation")), diff --git a/backend-compliance/compliance/api/agent_check/_phase_b_profile_check.py b/backend-compliance/compliance/api/agent_check/_phase_b_profile_check.py index b19c5ed9..df1e8a65 100644 --- a/backend-compliance/compliance/api/agent_check/_phase_b_profile_check.py +++ b/backend-compliance/compliance/api/agent_check/_phase_b_profile_check.py @@ -17,6 +17,7 @@ from dataclasses import asdict import httpx +from ._constants import _CAPTURE_ONLY from ._helpers import ( _apply_profile_filter, _doc_type_label, @@ -117,6 +118,16 @@ async def run_phase_b(state: dict) -> None: )) continue + # A: Capture-only — Text ist via doc_entries schon im Snapshot (für die + # Cross-Doc-Reconciliation B); hier NICHT scoren (keine eigene + # Checkliste → sonst irreführende 0%-Zeile). + if doc_type in _CAPTURE_ONLY: + results.append(DocCheckResult( + label=label, url=url, doc_type=doc_type, + error="Erfasst für Cross-Dokument-Abgleich (nicht einzeln bewertet).", + )) + continue + pct = int(40 + (i / n_entries) * 40) _update(check_id, f"Pruefen {i+1}/{n_entries}: {label}...", pct) diff --git a/backend-compliance/compliance/services/cookie_storage_inventory.py b/backend-compliance/compliance/services/cookie_storage_inventory.py index 0abf9d9e..cf417445 100644 --- a/backend-compliance/compliance/services/cookie_storage_inventory.py +++ b/backend-compliance/compliance/services/cookie_storage_inventory.py @@ -81,11 +81,15 @@ def build_storage_inventory(vendors: list[dict]) -> dict: """Zählt je Speichertyp + liefert Beispiele für Nicht-Cookies.""" by_type: dict[str, int] = {} examples: list[dict] = [] + per_cookie: dict[str, str] = {} for v in vendors or []: vname = v.get("name") or "?" for c in v.get("cookies") or []: st = detect_storage_type(c.get("name", ""), c.get("expiry", "")) by_type[st] = by_type.get(st, 0) + 1 + n = (c.get("name") or "").lower() + if n: + per_cookie[n] = st if st != "cookie" and len(examples) < 10: examples.append({ "name": c.get("name", ""), "type": st, "vendor": vname, @@ -98,6 +102,8 @@ def build_storage_inventory(vendors: list[dict]) -> dict: "real_cookies": cookies, "other_storage": total - cookies, "examples": examples, + # name_lower → Speichertyp (für den Frontend-Filter). + "per_cookie": per_cookie, } diff --git a/backend-compliance/compliance/tests/test_cookie_storage_inventory.py b/backend-compliance/compliance/tests/test_cookie_storage_inventory.py index 34cb41a4..d8be033e 100644 --- a/backend-compliance/compliance/tests/test_cookie_storage_inventory.py +++ b/backend-compliance/compliance/tests/test_cookie_storage_inventory.py @@ -50,6 +50,11 @@ def test_inventory_counts_and_transparency_finding(): tf = storage_transparency_finding(inv) assert tf and tf["type"] == "storage_transparency" assert "§ 25" in tf["control"]["article"] + # per_cookie-Map (für den Frontend-Storage-Filter): name_lower → Typ. + pc = inv["per_cookie"] + assert pc["componentdefstorage__mutex_x"] == "framework_storage" + assert pc["_ga"] == "cookie" + assert pc["browserid1"] == "cookie" def test_no_finding_when_all_real_cookies():