feat(cookie): Deklaration-vs-Bibliothek-Diff-Sicht + Funnel-KPI
Für die Library-getroffene Teilmesse (~32%) pro Cookie die Feld- Abweichungen deklariert→Library (Kategorie/Laufzeit/Zweck) als Diff-Karte, plus ehrlicher Funnel (gesamt → geprüft → abweichend) — nicht-getroffene Cookies sind nicht prüfbar (kein Pass/Fail), passend zur Tonalität. - analyze_cookies: 'expected'-Soll-Wert an tracker_as_necessary/ excessive_lifetime/missing_purpose (+ _CAT_LABEL_DE). - neues cookie_declaration_diff.build_declaration_diff: reine Regroup- Aggregation der Findings pro Cookie (single source = analyze_cookies), Hinweis-Typen (third_country/eu_alternative) bewusst ausgeschlossen. - cookie-check exponiert out['declaration_diff']. - CookieDeclarationDiff.tsx oben im Cookie-Tab (vor Panel/ResultView). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="text-xs text-gray-600 bg-slate-50 border border-gray-200 rounded-lg px-3 py-2">
|
||||||
|
<span className="font-semibold text-gray-800">{c.total}</span> Cookies ·{' '}
|
||||||
|
<span className="font-semibold text-gray-800">{c.checked}</span> gegen Bibliothek
|
||||||
|
geprüft (<span className="font-semibold">{pct}%</span>) · davon{' '}
|
||||||
|
<span className={`font-semibold ${c.discrepant > 0 ? 'text-red-700' : 'text-green-700'}`}>
|
||||||
|
{c.discrepant}
|
||||||
|
</span>{' '}
|
||||||
|
mit abweichender Deklaration
|
||||||
|
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
Nicht in der Bibliothek enthaltene Cookies sind nicht prüfbar (kein Pass, kein Fail).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CookieDeclarationDiff({ data }: { data?: DeclarationDiffData }) {
|
||||||
|
if (!data || !data.coverage) return null
|
||||||
|
const { coverage, rows } = data
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Deklaration vs. Bibliothek</h3>
|
||||||
|
</div>
|
||||||
|
<Funnel c={coverage} />
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-green-700 px-1">
|
||||||
|
Keine abweichenden Deklarationen in der geprüften Teilmenge.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 border-b text-xs">
|
||||||
|
<span className="font-mono font-medium text-gray-800 break-all">{r.cookie}</span>
|
||||||
|
{r.vendor && <span className="text-gray-400">· {r.vendor}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] ${SEV_BADGE[r.severity] || SEV_BADGE.LOW}`}>
|
||||||
|
{r.diffs.length} {r.diffs.length === 1 ? 'Abweichung' : 'Abweichungen'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 space-y-1">
|
||||||
|
{r.diffs.map((d, j) => (
|
||||||
|
<div key={j} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">{d.field}</span>
|
||||||
|
<span className="text-gray-600">{d.declared}</span>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
<span className={`font-medium ${d.severe ? 'text-red-700' : 'text-gray-900'}`}>
|
||||||
|
{d.expected}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{r.measures.length > 0 && (
|
||||||
|
<div className="text-[11px] text-blue-700 pt-1 border-t border-gray-100 mt-1">
|
||||||
|
<span className="font-medium">Maßnahme:</span> {r.measures.join(' ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(<CookieDeclarationDiff data={DATA} />)
|
||||||
|
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(<CookieDeclarationDiff data={undefined} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import React, { use as useUnwrap, useEffect, useMemo, useState } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel'
|
import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel'
|
||||||
|
import { CookieDeclarationDiff } from '../../_components/CookieDeclarationDiff'
|
||||||
import { CookieResultView } from '../../_components/CookieResultView'
|
import { CookieResultView } from '../../_components/CookieResultView'
|
||||||
import { AgentModuleTab } from '../../_components/AgentModuleTab'
|
import { AgentModuleTab } from '../../_components/AgentModuleTab'
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ export default function SnapshotDetail(
|
|||||||
|
|
||||||
{tab === 'cookie' && hasCookies && (
|
{tab === 'cookie' && hasCookies && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<CookieDeclarationDiff data={check?.declaration_diff} />
|
||||||
<CookieLibraryPanel snapshotId={snapshotId} data={check ?? undefined} />
|
<CookieLibraryPanel snapshotId={snapshotId} data={check ?? undefined} />
|
||||||
<CookieResultView snapshot={snap} cookieCategories={check?.cookie_categories} storageTypes={check?.storage_inventory?.per_cookie} />
|
<CookieResultView snapshot={snap} cookieCategories={check?.cookie_categories} storageTypes={check?.storage_inventory?.per_cookie} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ async def snapshot_cookie_check(snapshot_id: str):
|
|||||||
out["findings"].insert(0, tf)
|
out["findings"].insert(0, tf)
|
||||||
out["summary"]["findings"] = len(out["findings"])
|
out["summary"]["findings"] = len(out["findings"])
|
||||||
out["storage_inventory"] = inv
|
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.
|
# ② Documentation Drift: Cookie-Richtlinie (Text) vs. Browser-Realität.
|
||||||
docs = snap.get("doc_entries") or []
|
docs = snap.get("doc_entries") or []
|
||||||
cookie_text = next(
|
cookie_text = next(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ from sqlalchemy import text
|
|||||||
from compliance.services.cookie_knowledge_db import lookup_cookie
|
from compliance.services.cookie_knowledge_db import lookup_cookie
|
||||||
|
|
||||||
_TRACKER_CATS = {"marketing", "statistics", "social_media", "targeting"}
|
_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
|
# A — auditfeste Verdrahtung: jeder Befund-Typ → echter Control (control_id aus
|
||||||
# doc_check_controls) + legal_basis. Die Controls tragen regulation/article noch
|
# 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({
|
findings.append({
|
||||||
"vendor": vname, "cookie": name, "type": "tracker_as_necessary",
|
"vendor": vname, "cookie": name, "type": "tracker_as_necessary",
|
||||||
"severity": "HIGH" if rich.get("reid_risk") == "high" else "MEDIUM",
|
"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 + ".",
|
"remediation": rem + ".",
|
||||||
})
|
})
|
||||||
# 2) Kein Zweck deklariert, Library kennt ihn.
|
# 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({
|
findings.append({
|
||||||
"vendor": vname, "cookie": name, "type": "missing_purpose",
|
"vendor": vname, "cookie": name, "type": "missing_purpose",
|
||||||
"severity": "MEDIUM", "declared": "(kein Zweck angegeben)",
|
"severity": "MEDIUM", "declared": "(kein Zweck angegeben)",
|
||||||
|
"expected": purpose,
|
||||||
"library_purpose": purpose,
|
"library_purpose": purpose,
|
||||||
"remediation": f"Zweck für '{name}' ergänzen. Laut Library: {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",
|
"vendor": vname, "cookie": name, "type": "excessive_lifetime",
|
||||||
"severity": "LOW",
|
"severity": "LOW",
|
||||||
"declared": c.get("expiry", "") or "—",
|
"declared": c.get("expiry", "") or "—",
|
||||||
|
"expected": typ,
|
||||||
"library_purpose": f"typisch: {typ}",
|
"library_purpose": f"typisch: {typ}",
|
||||||
"remediation": (
|
"remediation": (
|
||||||
f"Speicherdauer von '{name}' ({c.get('expiry', '')}) "
|
f"Speicherdauer von '{name}' ({c.get('expiry', '')}) "
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
Reference in New Issue
Block a user