From 39cb6afc23210f1112f4aac1cee33a462ca261eb Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 11 Jun 2026 11:02:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(cookie):=20Findings=20bearbeitbar=20?= =?UTF-8?q?=E2=80=94=20gruppiert=20nach=20Typ=20+=20Matrix=20+=20Hinweise-?= =?UTF-8?q?Split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CookieFindings: Umschalter [Nach Fehlertyp] (je Typ: Maßnahme + betroffene Cookies + Ticket-Text) ↔ [Matrix] (Cookie×Typ, ✗ Handlung / ⚠ Hinweis). Trennung FINDINGS (zu beheben) vs HINWEISE (neutral, gegen DSE zu prüfen). Backend: kind-Klassifikation (third_country/eu_alternative=hinweis); Drittland- Remediation neutral formuliert (pro Verarbeiter prüfen, keine 'in DSE benennen'- Befehle, da DSE-Abdeckung wie BMWs 'in der Regel SCC' oft unzureichend). Co-Authored-By: Claude Opus 4.7 --- .../sdk/agent/_components/CookieFindings.tsx | 233 ++++++++++++++++++ .../agent/_components/CookieLibraryPanel.tsx | 116 +++------ .../__tests__/CookieFindings.test.tsx | 42 ++++ .../__tests__/CookieLibraryPanel.test.tsx | 7 +- .../services/cookie_library_check.py | 19 +- .../tests/test_cookie_library_check.py | 16 ++ 6 files changed, 346 insertions(+), 87 deletions(-) create mode 100644 admin-compliance/app/sdk/agent/_components/CookieFindings.tsx create mode 100644 admin-compliance/app/sdk/agent/_components/__tests__/CookieFindings.test.tsx diff --git a/admin-compliance/app/sdk/agent/_components/CookieFindings.tsx b/admin-compliance/app/sdk/agent/_components/CookieFindings.tsx new file mode 100644 index 00000000..b411a0a6 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/CookieFindings.tsx @@ -0,0 +1,233 @@ +'use client' + +/** + * CookieFindings — bereitet die Library-Befunde bearbeitbar auf, statt als + * Fließtext-Liste. Zwei Sichten (Umschalter): + * - Nach Fehlertyp: je Typ eine Maßnahme + betroffene Cookies + Ticket-Text + * (= eine Ticket-Einheit). Getrennt in FINDINGS (zu beheben) und HINWEISE + * (neutral, gegen DSE zu prüfen: Drittland, EU-Alternative). + * - Matrix: Zeilen = Cookies, Spalten = Fehlertypen, Markierung wo nachzubessern + * ist (ein Cookie, alle Probleme auf einen Blick). + */ + +import React, { useMemo, useState } from 'react' + +import type { CookieFinding } from './CookieLibraryPanel' + +const TYPE_LABEL: Record = { + tracker_as_necessary: 'Tracker als „notwendig" deklariert', + missing_purpose: 'Zweck fehlt', + excessive_lifetime: 'Speicherdauer zu lang', + vague_duration: 'Speicherdauer nicht konkret', + missing_retention: 'Keine Speicherdauer/Löschfrist', + storage_transparency: 'Speichertyp nicht transparent', + third_country: 'Drittland-Transfer', + eu_alternative: 'EU-Alternative verfügbar', +} +const TYPE_MEASURE: Record = { + tracker_as_necessary: 'Als einwilligungspflichtig einstufen (§ 25 Abs. 1 TDDDG).', + missing_purpose: 'Zweck je Cookie ergänzen (Art. 13 DSGVO).', + vague_duration: 'Konkrete Speicherdauer oder Löschkriterium angeben (Art. 5 Abs. 1 lit. e).', + missing_retention: 'Speicherdauer/Löschfrist je Verarbeiter festlegen (Art. 5 Abs. 1 lit. e).', + excessive_lifetime: 'Speicherdauer auf das Erforderliche reduzieren (Art. 5 Abs. 1 lit. e).', + storage_transparency: 'Speichertyp + -dauer je Objekt transparent ausweisen (§ 25 TDDDG).', + third_country: 'Geeignete Garantien je Verarbeiter prüfen (SCC Art. 46 / Art. 49).', + eu_alternative: 'EU-Alternative prüfen (kommerziell, kein Drittland-Transfer).', +} +const TYPE_ORDER = [ + 'tracker_as_necessary', 'missing_purpose', 'vague_duration', 'missing_retention', + 'excessive_lifetime', 'storage_transparency', 'third_country', 'eu_alternative', +] +const SEV_ORDER: Record = { HIGH: 0, MEDIUM: 1, LOW: 2 } +const SEV_COLOR: Record = { + HIGH: 'bg-red-100 text-red-700', + MEDIUM: 'bg-amber-100 text-amber-700', + LOW: 'bg-blue-100 text-blue-700', +} + +interface Group { type: string; items: CookieFinding[]; severity: string } + +function groupByType(findings: CookieFinding[]): Group[] { + const m = new Map() + for (const f of findings) { + if (!m.has(f.type)) m.set(f.type, []) + m.get(f.type)!.push(f) + } + const groups = [...m.entries()].map(([type, items]) => ({ + type, items, + severity: items.reduce( + (s, f) => (SEV_ORDER[f.severity] ?? 3) < (SEV_ORDER[s] ?? 3) ? f.severity : s, 'LOW'), + })) + groups.sort((a, b) => + (TYPE_ORDER.indexOf(a.type) + 99) % 100 - (TYPE_ORDER.indexOf(b.type) + 99) % 100) + return groups +} + +function cookieLabel(f: CookieFinding): string { + const v = f.vendor && f.vendor !== '—' ? ` (${f.vendor})` : '' + const d = f.declared ? ` — ${f.declared}` : '' + return `${f.cookie}${v}${d}` +} + +function ticketText(g: Group): string { + return [ + `${TYPE_LABEL[g.type] || g.type} — ${g.items.length} betroffen`, + `Maßnahme: ${TYPE_MEASURE[g.type] || ''}`, + '', + ...g.items.map(f => `- ${cookieLabel(f)}`), + ].join('\n') +} + +function GroupCard({ g }: { g: Group }) { + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) + const copy = () => { + navigator.clipboard?.writeText(ticketText(g)).then(() => { + setCopied(true); setTimeout(() => setCopied(false), 1500) + }).catch(() => {}) + } + return ( +
+ + {open && ( +
+
+ Maßnahme: {TYPE_MEASURE[g.type] || '—'} +
+ + + {g.items.map((f, i) => ( + + + + + + ))} + +
{f.cookie}{f.vendor}{f.declared || ''}
+ +
+ )} +
+ ) +} + +function Section({ title, hint, groups }: { title: string; hint?: string; groups: Group[] }) { + if (!groups.length) return null + return ( +
+
+ {title} + {hint && {hint}} +
+ {groups.map(g => )} +
+ ) +} + +function Matrix({ findings }: { findings: CookieFinding[] }) { + const { rows, cols } = useMemo(() => { + const colSet = new Set(findings.map(f => f.type)) + const cols = TYPE_ORDER.filter(t => colSet.has(t)) + const rowMap = new Map }>() + for (const f of findings) { + const key = `${f.cookie}@@${f.vendor}` + if (!rowMap.has(key)) rowMap.set(key, { label: f.cookie, vendor: f.vendor, hits: {} }) + rowMap.get(key)!.hits[f.type] = (f.kind === 'hinweis') ? '⚠' : '✗' + } + return { rows: [...rowMap.values()], cols } + }, [findings]) + + return ( +
+ + + + + {cols.map(c => ( + + ))} + + + + {rows.map((r, i) => ( + + + {cols.map(c => ( + + ))} + + ))} + +
Cookie + {(TYPE_LABEL[c] || c).split(' ')[0]} +
+ {r.label} + {r.vendor && r.vendor !== '—' && · {r.vendor}} + + {r.hits[c] || '·'} +
+
+ ✗ = Handlung nötig · ⚠ = Hinweis (zu prüfen) · Spalte = Fehlertyp (Tooltip) +
+
+ ) +} + +export function CookieFindings({ findings }: { findings: CookieFinding[] }) { + const [mode, setMode] = useState<'type' | 'matrix'>('type') + const real = findings.filter(f => (f.kind ?? 'finding') !== 'hinweis') + const hints = findings.filter(f => (f.kind ?? 'finding') === 'hinweis') + + if (!findings.length) { + return
Keine Abweichungen gegen die Library.
+ } + + const btn = (m: 'type' | 'matrix', label: string) => ( + + ) + + return ( +
+
+ + {findings.length} Befund{findings.length !== 1 ? 'e' : ''} + + {real.length} zu beheben · {hints.length} Hinweise + + +
+ {btn('type', 'Nach Fehlertyp')} + {btn('matrix', 'Matrix')} +
+
+ + {mode === 'matrix' ? ( + + ) : ( +
+
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx b/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx index 6fb7b6cf..06bb0872 100644 --- a/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx +++ b/admin-compliance/app/sdk/agent/_components/CookieLibraryPanel.tsx @@ -8,6 +8,8 @@ import React, { useEffect, useState } from 'react' +import { CookieFindings } from './CookieFindings' + export interface CookieFinding { vendor: string cookie: string @@ -16,6 +18,7 @@ export interface CookieFinding { declared: string library_purpose: string remediation: string + kind?: string control?: { control_id?: string | null; regulation?: string; article?: string } } @@ -36,21 +39,6 @@ interface CheckData { } } -const SEV_COLOR: Record = { - HIGH: 'bg-red-100 text-red-700', - MEDIUM: 'bg-amber-100 text-amber-700', - LOW: 'bg-blue-100 text-blue-700', -} -const TYPE_LABEL: Record = { - tracker_as_necessary: 'Tracker als „notwendig" deklariert', - missing_purpose: 'Zweck fehlt', - excessive_lifetime: 'Speicherdauer zu lang', - vague_duration: 'Speicherdauer nicht konkret', - missing_retention: 'Keine Speicherdauer/Löschfrist', - third_country: 'Drittland-Transfer', - eu_alternative: 'EU-Alternative verfügbar', - storage_transparency: 'Speichertyp nicht transparent', -} const STORAGE_LABEL: Record = { cookie: 'Cookies', local_storage: 'Local Storage', session_storage: 'Session Storage', indexeddb: 'IndexedDB', @@ -66,75 +54,45 @@ export function CookieFindingList({ data }: { data: CheckData }) { const driftShown = !!drift && ((drift.declared_count ?? 0) + (drift.browser_count ?? 0)) > 0 return ( -
- {driftShown && ( -
- Richtlinie ↔ Realität:{' '} - {drift!.declared_count ?? 0} in der Cookie-Richtlinie - dokumentiert · {drift!.browser_count ?? 0} im Browser geladen - {(drift!.high_findings ?? 0) > 0 && ( - <> · {drift!.high_findings} undokumentiert geladen - )} - {(drift!.low_findings ?? 0) > 0 && ( - <> · {drift!.low_findings} dokumentiert, aber nicht geladen - )} -
- )} - {inv && (inv.total ?? 0) > 0 && ( -
- Storage-Inventar:{' '} - {inv.total} als „Cookies" gelistet →{' '} - {inv.real_cookies} echte Cookies - {(inv.other_storage ?? 0) > 0 && ( - <> + {inv.other_storage} andere Endgeräte-Speicher - )} - {inv.by_type && ( - - ({Object.entries(inv.by_type) - .map(([k, n]) => `${n} ${STORAGE_LABEL[k] || k}`) - .join(' · ')}) - - )} -
- )} -
- Library-Abgleich — {findings.length} Befund{findings.length !== 1 ? 'e' : ''} - - {s.in_library ?? 0}/{s.checked ?? 0} Cookies in der Library erkannt - -
- {findings.length === 0 ? ( -
- Keine Abweichungen gegen die Library. -
- ) : ( -
- {findings.map((f, i) => ( -
-
- - {f.severity} - - {f.cookie} - · {f.vendor} - - {TYPE_LABEL[f.type] || f.type} - -
- {f.library_purpose && ( -
Library-Zweck: {f.library_purpose}
+
+ {(driftShown || (inv && (inv.total ?? 0) > 0)) && ( +
+ {driftShown && ( +
+ Richtlinie ↔ Realität:{' '} + {drift!.declared_count ?? 0} in der Cookie-Richtlinie + dokumentiert · {drift!.browser_count ?? 0} im Browser geladen + {(drift!.high_findings ?? 0) > 0 && ( + <> · {drift!.high_findings} undokumentiert geladen )} -
{f.remediation}
- {f.control?.regulation && f.control.regulation !== '—' && ( -
- Rechtsgrundlage: {f.control.regulation} {f.control.article} - {f.control.control_id && ` · Control ${f.control.control_id}`} -
+ {(drift!.low_findings ?? 0) > 0 && ( + <> · {drift!.low_findings} dokumentiert, aber nicht geladen )}
- ))} + )} + {inv && (inv.total ?? 0) > 0 && ( +
+ Storage-Inventar:{' '} + {inv.total} als „Cookies" gelistet →{' '} + {inv.real_cookies} echte Cookies + {(inv.other_storage ?? 0) > 0 && ( + <> + {inv.other_storage} andere Endgeräte-Speicher + )} + {inv.by_type && ( + + ({Object.entries(inv.by_type) + .map(([k, n]) => `${n} ${STORAGE_LABEL[k] || k}`) + .join(' · ')}) + + )} +
+ )}
)} +
+ {s.in_library ?? 0}/{s.checked ?? 0} Cookies in der Library erkannt +
+
) } diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/CookieFindings.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/CookieFindings.test.tsx new file mode 100644 index 00000000..78f898ce --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/__tests__/CookieFindings.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +import { CookieFindings } from '../CookieFindings' + +const FINDINGS = [ + { vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary', severity: 'HIGH', + declared: 'necessary', library_purpose: '', remediation: '', kind: 'finding' }, + { vendor: 'Acme', cookie: 'foo', type: 'missing_purpose', severity: 'MEDIUM', + declared: '', library_purpose: '', remediation: '', kind: 'finding' }, + { vendor: 'Google', cookie: '_gid', type: 'third_country', severity: 'MEDIUM', + declared: 'US', library_purpose: '', remediation: '', kind: 'hinweis' }, +] + +describe('CookieFindings', () => { + it('gruppiert nach Typ und trennt Findings von Hinweisen', () => { + render() + expect(screen.getByText(/3 Befunde/)).toBeInTheDocument() + expect(screen.getByText(/Findings — zu beheben/)).toBeInTheDocument() + expect(screen.getByText(/Hinweise — neutral/)).toBeInTheDocument() + expect(screen.getByText(/Tracker als/)).toBeInTheDocument() + expect(screen.getByText('Drittland-Transfer')).toBeInTheDocument() + }) + + it('klappt eine Gruppe auf und zeigt Maßnahme + Ticket-Button', () => { + render() + fireEvent.click(screen.getByText(/Zweck fehlt/)) + expect(screen.getByText(/Maßnahme:/)).toBeInTheDocument() + expect(screen.getByText(/Ticket-Text kopieren/)).toBeInTheDocument() + }) + + it('schaltet auf die Matrix-Sicht um', () => { + render() + fireEvent.click(screen.getByText('Matrix')) + expect(screen.getByText(/Handlung nötig/)).toBeInTheDocument() + }) + + it('zeigt grünen Hinweis bei 0 Befunden', () => { + render() + expect(screen.getByText(/Keine Abweichungen/)).toBeInTheDocument() + }) +}) diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/CookieLibraryPanel.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/CookieLibraryPanel.test.tsx index 9e05ec19..b47baf38 100644 --- a/admin-compliance/app/sdk/agent/_components/__tests__/CookieLibraryPanel.test.tsx +++ b/admin-compliance/app/sdk/agent/_components/__tests__/CookieLibraryPanel.test.tsx @@ -4,21 +4,20 @@ import { render, screen } from '@testing-library/react' import { CookieFindingList } from '../CookieLibraryPanel' describe('CookieFindingList', () => { - it('zeigt Befunde mit Severity, Library-Zweck + Maßnahme', () => { + it('zeigt Befunde gruppiert nach Typ mit Severity + Library-Count', () => { const data = { summary: { checked: 10, in_library: 4, findings: 1 }, findings: [{ vendor: 'Salesforce', cookie: '_ga', type: 'tracker_as_necessary', severity: 'HIGH', declared: 'necessary', library_purpose: 'Besucher eindeutig unterscheiden', - remediation: 'Als einwilligungspflichtig (§ 25 TDDDG) einstufen.', + remediation: 'Als einwilligungspflichtig (§ 25 TDDDG) einstufen.', kind: 'finding', }], } render() expect(screen.getByText(/1 Befund/)).toBeInTheDocument() - expect(screen.getByText('_ga')).toBeInTheDocument() expect(screen.getByText('HIGH')).toBeInTheDocument() - expect(screen.getByText(/§ 25 TDDDG/)).toBeInTheDocument() + expect(screen.getByText(/Tracker als/)).toBeInTheDocument() // Gruppen-Header expect(screen.getByText(/4\/10 Cookies/)).toBeInTheDocument() }) diff --git a/backend-compliance/compliance/services/cookie_library_check.py b/backend-compliance/compliance/services/cookie_library_check.py index 8ad1f9b3..1954b39d 100644 --- a/backend-compliance/compliance/services/cookie_library_check.py +++ b/backend-compliance/compliance/services/cookie_library_check.py @@ -39,6 +39,11 @@ _CONTROL_MAP = { "eu_alternative": {"control_id": None, "regulation": "—", "article": "kommerzielle Empfehlung"}, } +# Advisory-Typen: keine bestätigten Verstöße, sondern Hinweise, die der +# Cross-Finding-Agent gegen die DSE abgleicht (Drittland kann dort bereits via +# SCC/Art. 49/Angemessenheit abgedeckt sein → dann unterdrücken). +_HINWEIS_TYPES = {"third_country", "eu_alternative"} + def load_big_library(db, names: list[str]) -> dict: """Batch-Lookup der grossen Open-Cookie-Database (compliance.cookie_library, @@ -222,9 +227,13 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: "declared": country or "—", "library_purpose": schrems or f"Anbieter-Sitz {country}", "remediation": ( - f"{vname} überträgt in ein Drittland ({country or 'außerhalb EWR'}) — " - f"SCC (Art. 46) oder DPF-Zertifizierung prüfen und in der " - f"Datenschutzerklärung benennen (Art. 44 ff. DSGVO)." + f"Neutrales Finding: {vname} kann Daten außerhalb der EU " + f"({country or 'Drittland'}) verarbeiten. Für jeden solchen " + f"Verarbeiter geeignete Garantien konkret nachweisen (SCC Art. 46 / " + f"Angemessenheitsbeschluss / Art. 49) und ggf. eine " + f"Transfer-Folgenabschätzung (TIA). Pauschale DSE-Formulierungen " + f"('in der Regel SCC') genügen nicht — pro Verarbeiter prüfen " + f"(Art. 44 ff. DSGVO). Interne Verträge können wir nicht einsehen." ), }) @@ -262,9 +271,11 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: ), }) - # A: jeden Befund an seinen Control + Rechtsgrundlage haengen (auditfest). + # A: jeden Befund an Control + Rechtsgrundlage haengen + als echtes Finding + # (zu beheben) oder Hinweis (advisory, gegen DSE abzugleichen) klassifizieren. for f in findings: f["control"] = _CONTROL_MAP.get(f["type"], {}) + f["kind"] = "hinweis" if f["type"] in _HINWEIS_TYPES else "finding" findings.sort(key=lambda f: _SEV_ORDER.get(f["severity"], 3)) return { "summary": { diff --git a/backend-compliance/compliance/tests/test_cookie_library_check.py b/backend-compliance/compliance/tests/test_cookie_library_check.py index d3952d1f..4e71d8ec 100644 --- a/backend-compliance/compliance/tests/test_cookie_library_check.py +++ b/backend-compliance/compliance/tests/test_cookie_library_check.py @@ -58,6 +58,22 @@ def test_third_country_and_eu_alternative_for_us_tracker(): assert "eu_alternative" in t +def test_kind_splits_findings_from_hinweise(): + # third_country/eu_alternative = Hinweis (advisory); Rest = Finding. + out = analyze_cookies([{ + "name": "Google", "category": "necessary", + "cookies": [{"name": "_ga", "purpose": "", "expiry": "2 Jahre"}], + }]) + by = {f["type"]: f["kind"] for f in out["findings"]} + assert by.get("third_country") == "hinweis" + assert by.get("eu_alternative") == "hinweis" + assert by.get("tracker_as_necessary") == "finding" + # Drittland-Wording: neutral, pro Verarbeiter, keine "in DSE benennen"-Befehle. + tc = next(f for f in out["findings"] if f["type"] == "third_country") + assert "pro Verarbeiter" in tc["remediation"] + assert "benennen" not in tc["remediation"] + + def test_third_country_deduped_per_vendor(): out = analyze_cookies([{ "name": "Google", "category": "marketing",