diff --git a/admin-compliance/app/sdk/agent/_components/CookieDeclarationDiff.tsx b/admin-compliance/app/sdk/agent/_components/CookieDeclarationDiff.tsx new file mode 100644 index 00000000..c37f78fa --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/CookieDeclarationDiff.tsx @@ -0,0 +1,104 @@ +'use client' + +/** + * CookieDeclarationDiff — „Deklaration vs. Bibliothek". + * + * Zeigt pro Cookie der GEPRÜFTEN Teilmenge (Library-Treffer) die Feld- + * Abweichungen deklariert → Library, plus einen ehrlichen Funnel + * (gesamt → geprüft → abweichend). Quelle: cookie-check `declaration_diff`. + */ + +import React from 'react' + +interface Diff { + field: string + declared: string + expected: string + severe?: boolean +} +interface DiffRow { + cookie: string + vendor: string + severity: string + diffs: Diff[] + measures: string[] +} +export interface DeclarationDiffData { + coverage: { total: number; checked: number; discrepant: number } + rows: DiffRow[] +} + +const SEV_BADGE: Record = { + HIGH: 'bg-red-100 text-red-700', + MEDIUM: 'bg-amber-100 text-amber-700', + LOW: 'bg-gray-100 text-gray-600', +} + +function Funnel({ c }: { c: DeclarationDiffData['coverage'] }) { + const pct = c.total > 0 ? Math.round((c.checked / c.total) * 100) : 0 + return ( +
+ {c.total} Cookies ·{' '} + {c.checked} gegen Bibliothek + geprüft ({pct}%) · davon{' '} + 0 ? 'text-red-700' : 'text-green-700'}`}> + {c.discrepant} + {' '} + mit abweichender Deklaration +
+ Nicht in der Bibliothek enthaltene Cookies sind nicht prüfbar (kein Pass, kein Fail). +
+
+ ) +} + +export function CookieDeclarationDiff({ data }: { data?: DeclarationDiffData }) { + if (!data || !data.coverage) return null + const { coverage, rows } = data + return ( +
+
+

Deklaration vs. Bibliothek

+
+ + + {rows.length === 0 ? ( +

+ Keine abweichenden Deklarationen in der geprüften Teilmenge. +

+ ) : ( +
+ {rows.map((r, i) => ( +
+
+ {r.cookie} + {r.vendor && · {r.vendor}} + + + {r.diffs.length} {r.diffs.length === 1 ? 'Abweichung' : 'Abweichungen'} + +
+
+ {r.diffs.map((d, j) => ( +
+ {d.field} + {d.declared} + + + {d.expected} + +
+ ))} + {r.measures.length > 0 && ( +
+ Maßnahme: {r.measures.join(' ')} +
+ )} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/CookieDeclarationDiff.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/CookieDeclarationDiff.test.tsx new file mode 100644 index 00000000..3122c392 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/__tests__/CookieDeclarationDiff.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' + +import { CookieDeclarationDiff } from '../CookieDeclarationDiff' + +const DATA = { + coverage: { total: 761, checked: 244, discrepant: 1 }, + rows: [{ + cookie: '_ga', vendor: 'Google Analytics', severity: 'HIGH', + diffs: [ + { field: 'Kategorie', declared: 'notwendig', expected: 'Marketing', severe: true }, + { field: 'Laufzeit', declared: 'Session', expected: '2 Jahre' }, + ], + measures: ['Als einwilligungspflichtig (§ 25) einstufen.'], + }], +} + +describe('CookieDeclarationDiff', () => { + it('zeigt den Funnel + Feld-Diffs deklariert→Library', () => { + render() + expect(screen.getByText('761')).toBeInTheDocument() // gesamt + expect(screen.getByText('244')).toBeInTheDocument() // geprüft + expect(screen.getByText('_ga')).toBeInTheDocument() + expect(screen.getByText('Kategorie')).toBeInTheDocument() + expect(screen.getByText('Marketing')).toBeInTheDocument() // Soll-Wert + expect(screen.getByText(/2 Abweichungen/)).toBeInTheDocument() + expect(screen.getByText(/Als einwilligungspflichtig/)).toBeInTheDocument() + }) + + it('rendert nichts ohne Daten', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx index e617c84e..5bc3f7dd 100644 --- a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -12,6 +12,7 @@ import React, { use as useUnwrap, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel' +import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff' import { CookieResultView } from '../../_components/CookieResultView' import { AgentModuleTab } from '../../_components/AgentModuleTab' @@ -93,6 +94,7 @@ export default function SnapshotDetail( {tab === 'cookie' && hasCookies && (
+
diff --git a/backend-compliance/compliance/api/snapshot_check_routes.py b/backend-compliance/compliance/api/snapshot_check_routes.py index c7dc7088..8da99352 100644 --- a/backend-compliance/compliance/api/snapshot_check_routes.py +++ b/backend-compliance/compliance/api/snapshot_check_routes.py @@ -80,6 +80,11 @@ async def snapshot_cookie_check(snapshot_id: str): out["findings"].insert(0, tf) out["summary"]["findings"] = len(out["findings"]) out["storage_inventory"] = inv + # Deklaration-vs-Bibliothek-Diff (nur die getroffene Teilmenge) + Funnel. + from compliance.services.cookie_declaration_diff import ( + build_declaration_diff, + ) + out["declaration_diff"] = build_declaration_diff(out) # ② Documentation Drift: Cookie-Richtlinie (Text) vs. Browser-Realität. docs = snap.get("doc_entries") or [] cookie_text = next( diff --git a/backend-compliance/compliance/services/cookie_declaration_diff.py b/backend-compliance/compliance/services/cookie_declaration_diff.py new file mode 100644 index 00000000..b1f42bdc --- /dev/null +++ b/backend-compliance/compliance/services/cookie_declaration_diff.py @@ -0,0 +1,70 @@ +"""Deklaration-vs-Bibliothek-Diff. + +Regroupt die `analyze_cookies`-Findings PRO COOKIE zu Feld-Diffs +(deklariert → Library) — nur für die Library-getroffene Teilmenge, denn nur +dort gibt es eine Ground-Truth. Plus ein ehrlicher Funnel (gesamt → geprüft → +abweichend), damit nie der Eindruck entsteht, ALLE Cookies seien geprüft +(passt zur BreakPilot-Tonalität: nicht-getroffene Cookies = nicht prüfbar, +kein Pass, kein Fail). + +Single source of truth bleibt `analyze_cookies` (Erkennung); dieses Modul ist +reine Präsentations-Aggregation und damit isoliert testbar. +""" + +from __future__ import annotations + +# Finding-Typ → Feld-Label. Nur Typen mit echtem Library-Soll ('expected'). +# vague_duration/missing_retention/missing_opt_out haben KEINEN Library-Vergleich +# und third_country/eu_alternative sind Hinweise → bewusst NICHT im Diff. +_FIELD = { + "tracker_as_necessary": "Kategorie", + "excessive_lifetime": "Laufzeit", + "missing_purpose": "Zweck", +} +_SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + + +def build_declaration_diff(analysis: dict) -> dict: + """Aus dem `analyze_cookies`-Ergebnis die Diff-Sicht + Funnel bauen.""" + findings = analysis.get("findings") or [] + summary = analysis.get("summary") or {} + + rows: dict[tuple, dict] = {} + for f in findings: + field = _FIELD.get(f.get("type")) + if not field: + continue + key = (f.get("vendor") or "", f.get("cookie") or "") + row = rows.get(key) + if row is None: + row = { + "cookie": f.get("cookie") or "", + "vendor": f.get("vendor") or "", + "diffs": [], + "measures": [], + "severity": "LOW", + } + rows[key] = row + row["diffs"].append({ + "field": field, + "declared": str(f.get("declared") or "—"), + "expected": str(f.get("expected") or f.get("library_purpose") or "—"), + "severe": f.get("severity") == "HIGH", + }) + rem = f.get("remediation") + if rem and rem not in row["measures"]: + row["measures"].append(rem) + if _SEV_ORDER.get(f.get("severity"), 3) < _SEV_ORDER.get(row["severity"], 3): + row["severity"] = f.get("severity") or "LOW" + + out_rows = sorted(rows.values(), key=lambda r: _SEV_ORDER.get(r["severity"], 3)) + total = int(summary.get("checked") or 0) # alle Cookies + checked = int(summary.get("in_library") or 0) # davon mit Library-Treffer + return { + "coverage": { + "total": total, + "checked": checked, + "discrepant": len(out_rows), + }, + "rows": out_rows, + } diff --git a/backend-compliance/compliance/services/cookie_library_check.py b/backend-compliance/compliance/services/cookie_library_check.py index b097baf6..368cc386 100644 --- a/backend-compliance/compliance/services/cookie_library_check.py +++ b/backend-compliance/compliance/services/cookie_library_check.py @@ -24,6 +24,12 @@ from sqlalchemy import text from compliance.services.cookie_knowledge_db import lookup_cookie _TRACKER_CATS = {"marketing", "statistics", "social_media", "targeting"} +# Library-Kategorie → deutsches Label (für die Deklaration-vs-Library-Diff-Sicht). +_CAT_LABEL_DE = { + "marketing": "Marketing", "statistics": "Statistik", + "social_media": "Social Media", "targeting": "Targeting", + "functional": "Funktional", "necessary": "Notwendig", +} # A — auditfeste Verdrahtung: jeder Befund-Typ → echter Control (control_id aus # doc_check_controls) + legal_basis. Die Controls tragen regulation/article noch @@ -238,7 +244,9 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: findings.append({ "vendor": vname, "cookie": name, "type": "tracker_as_necessary", "severity": "HIGH" if rich.get("reid_risk") == "high" else "MEDIUM", - "declared": vcat_label, "library_purpose": purpose, + "declared": vcat_label, + "expected": _CAT_LABEL_DE.get(actual_cat, "einwilligungspflichtig"), + "library_purpose": purpose, "remediation": rem + ".", }) # 2) Kein Zweck deklariert, Library kennt ihn. @@ -246,6 +254,7 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: findings.append({ "vendor": vname, "cookie": name, "type": "missing_purpose", "severity": "MEDIUM", "declared": "(kein Zweck angegeben)", + "expected": purpose, "library_purpose": purpose, "remediation": f"Zweck für '{name}' ergänzen. Laut Library: {purpose}", }) @@ -264,6 +273,7 @@ def analyze_cookies(vendors: list[dict], big_lib: dict | None = None) -> dict: "vendor": vname, "cookie": name, "type": "excessive_lifetime", "severity": "LOW", "declared": c.get("expiry", "") or "—", + "expected": typ, "library_purpose": f"typisch: {typ}", "remediation": ( f"Speicherdauer von '{name}' ({c.get('expiry', '')}) " diff --git a/backend-compliance/compliance/tests/test_cookie_declaration_diff.py b/backend-compliance/compliance/tests/test_cookie_declaration_diff.py new file mode 100644 index 00000000..261880b1 --- /dev/null +++ b/backend-compliance/compliance/tests/test_cookie_declaration_diff.py @@ -0,0 +1,39 @@ +"""Deklaration-vs-Bibliothek-Diff: Regroup pro Cookie + Funnel.""" + +from __future__ import annotations + +from compliance.services.cookie_declaration_diff import build_declaration_diff + + +def test_groups_diffs_per_cookie_with_funnel(): + analysis = { + "summary": {"checked": 100, "in_library": 32, "findings": 4}, + "findings": [ + {"vendor": "Google", "cookie": "_ga", "type": "tracker_as_necessary", + "severity": "HIGH", "declared": "notwendig", "expected": "Marketing", + "remediation": "Als § 25 einstufen."}, + {"vendor": "Google", "cookie": "_ga", "type": "excessive_lifetime", + "severity": "LOW", "declared": "Session", "expected": "730 Tage", + "remediation": "Laufzeit prüfen."}, + {"vendor": "Meta", "cookie": "_fbp", "type": "missing_purpose", + "severity": "MEDIUM", "declared": "(kein Zweck)", "expected": "Tracking", + "remediation": "Zweck ergänzen."}, + # Hinweis-Typ ohne Library-Soll → NICHT im Diff. + {"vendor": "Google", "cookie": "_ga", "type": "third_country", + "severity": "MEDIUM", "declared": "US"}, + ], + } + out = build_declaration_diff(analysis) + assert out["coverage"] == {"total": 100, "checked": 32, "discrepant": 2} + ga = next(r for r in out["rows"] if r["cookie"] == "_ga") + assert {d["field"] for d in ga["diffs"]} == {"Kategorie", "Laufzeit"} + assert ga["severity"] == "HIGH" # höchste der beiden + assert ga["diffs"][0]["severe"] is True + assert out["rows"][0]["cookie"] == "_ga" # HIGH zuerst sortiert + + +def test_no_discrepancies_yields_empty_rows(): + out = build_declaration_diff( + {"summary": {"checked": 10, "in_library": 4}, "findings": []}) + assert out["coverage"] == {"total": 10, "checked": 4, "discrepant": 0} + assert out["rows"] == []