Compare commits

...

3 Commits

Author SHA1 Message Date
Benjamin Admin 98ec6d4284 fix(report): Anti-Pattern-Aufgabe — "muss entfernt werden" statt "ergaenzt werden"
CI / detect-changes (push) Successful in 9s
CI / secret-scan (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Bug: bei invertierten Checks (P9 #7 illegal_disclaimer) sagte die
GF-Aufgaben-Liste "muss ergaenzt werden" — semantisch falsch, weil der
Disclaimer ja schon da IST und entfernt werden soll.

Fix: _check_to_action() erkennt jetzt Anti-Pattern-Labels
(rechtswidrig/illegal/haftungsausschluss/disclaimer) und gibt
"muss entfernt werden (Anti-Pattern, rechtlich wirkungslos)" zurueck.

Smoke-Test BMW d2f7bcc0: vorher 'Rechtswidriger Haftungsausschluss
muss ergaenzt werden' -> jetzt 'muss entfernt werden'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:40:24 +02:00
Benjamin Admin 6f16507c5f feat(banner): P19 + P20 — Per-Category-Click-Test + Frontend-Drilldown
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m54s
CI / test-go (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P19 (consent-tester):
- dp-cookieconsent (TYPO3, Safetykon-Pattern) als CMP-Profil hinzu —
  Selektoren #dp--cookie-statistics/marketing + a.cc-allow Save-Button
- Neues Signal provider_details_visible: nach Kategorie-Toggle prueft
  Playwright ob im Banner sichtbare Provider-/Cookie-Detail-Elemente
  erscheinen. Bei dp-cookieconsent (Banner ohne Listing) immer False
  -> HIGH-Violation "Kategorie zeigt keine Provider-/Cookie-Details —
  Nutzer kann nicht informiert einwilligen (Art. 7 Abs. 1 DSGVO)"
- main.py serialisiert provider_details_visible + cookies_set pro Kategorie

P20 (Frontend-Drilldown):
- Backend: check_payloads-Tabelle um Spalte 'banner' (JSON) — voller
  banner_result persistiert (vorher nur in-memory). ALTER TABLE
  Migration idempotent.
- Neuer Endpoint GET /api/compliance/agent/banner/<check_id> — liefert
  Quality-Score, Phases, Category-Tests, Banner-Checks, alle 46
  structured_checks.
- Frontend: BannerTab im /sdk/agent/audit/<id> mit Quality-Cards,
  3-Phasen-Cookie-Tabelle, Per-Category-Listing (mit P19-Signal
  rot/gruen), Banner-Verstoesse + Rechtsgrundlagen, 46-Check-Drilldown
  filterbar nach Severity.
- Tab-Switcher in page.tsx um "Cookie-Banner-Analyse" erweitert.
- Bonus: 2 alte route.ts auf Next.js 15 Promise-params umgestellt
  (Build-Fix).

Plus: Critical-Findings-Block nutzt provider_details_visible als
primaeres Signal statt nur tracking_services-Anzahl.

Smoke-Test Safetykon: 4 Critical Findings im Mail, banner-Endpoint
liefert 46 checks + 3 phases + 2 categories mit provider_details_visible=False.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:31:13 +02:00
Benjamin Admin d4d9b60007 feat(email): P18 — Critical-Findings-Box + Banner-Deep-Block
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / loc-budget (push) Successful in 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m8s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 47s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Backend wirft 90% der consent-tester-Daten weg — nur 4 Felder von einem
vollen Banner-Scan landeten im Email. Phases (before_consent / after_reject
/ after_accept), banner_checks.violations mit Rechtsgrundlagen,
category_tests, 46 structured_checks, completeness/correctness-Scores
waren alle nicht sichtbar.

Backend: agent_compliance_check_routes leitet jetzt das volle banner_result
durch (15 Felder statt 4).

Renderer (2 neue Module):
1) agent_doc_check_critical.build_critical_findings_html
   - ROTER Sofortmassnahmen-Block GANZ OBEN in der Email
   - Erkennt: banner-violations (HIGH/CRITICAL), leere Per-Category-Lists,
     DSE-Score <30%, fehlende Cookie-Richtlinie, US-Tracker ohne SCC/DPF
   - Pro Issue: konkrete Sofortmassnahme + Rechtsgrundlage + Bussgeld-
     Praezedenz (CNIL TikTok 5 Mio, LfDI BW 30k, EuGH Schrems II, ...)
   - Wird nur gerendert wenn echte Issues vorliegen

2) agent_doc_check_banner.build_banner_deep_html
   - Banner-Quality-Score-Cards (Vollstaendigkeit / Korrektheit / Verstoesse)
   - 3-Phasen-Cookie-Tabelle: vor Consent / nach Ablehnung / nach Annahme
     mit Cookie-Count, Tracker-Count, Auffaelligkeiten
   - Per-Category-Tracker-Listing (Statistik/Marketing) — zeigt explizit
     wenn eine Kategorie keine Provider listet (Safetykon-Pattern)
   - Violations-Liste mit Severity-Badge + Quellen-Hint (LG Rostock, EDPB)

Smoke-Test Safetykon: alle 6 neuen Blocks rendern, kein Regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:34:17 +02:00
13 changed files with 916 additions and 20 deletions
@@ -10,9 +10,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
{ params }: { params: Promise<{ checkId: string }> },
) {
const checkId = params.checkId
const { checkId } = await params
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/audit/${checkId}${qs ? `?${qs}` : ''}`
try {
@@ -0,0 +1,28 @@
/**
* Proxy: GET /api/sdk/v1/agent/banner/<checkId>
* -> backend GET /api/compliance/agent/banner/<checkId>
*
* Liefert das volle banner_result (phases, structured_checks, category_tests).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ checkId: string }> },
) {
const { checkId } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/compliance/agent/banner/${checkId}`,
{ signal: AbortSignal.timeout(15000) },
)
const data = await resp.json().catch(() => ({}))
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Banner-Abfrage fehlgeschlagen' }, { status: 503 },
)
}
}
@@ -10,9 +10,9 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
{ params }: { params: Promise<{ checkId: string }> },
) {
const checkId = params.checkId
const { checkId } = await params
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/findings/${checkId}${qs ? `?${qs}` : ''}`
try {
@@ -0,0 +1,302 @@
'use client'
import React, { useEffect, useState } from 'react'
type Phase = {
cookies?: string[]
scripts?: string[]
tracking_services?: (string | { name?: string })[]
new_tracking?: unknown[]
violations?: Array<{ severity?: string; text?: string }>
undocumented?: unknown[]
}
type CategoryTest = {
category: string
category_label: string
tracking_services?: (string | { name?: string })[]
cookies_set?: string[]
provider_details_visible?: boolean
violations?: Array<{ severity?: string; text?: string; legal_ref?: string }>
}
type BannerViolation = {
severity?: string
text?: string
legal_ref?: string
}
type StructuredCheck = {
id: string
label: string
passed: boolean
skipped?: boolean
severity: string
level?: number
hint?: string
}
type BannerResp = {
found: boolean
check_id: string
banner?: {
banner_provider?: string
banner_detected?: boolean
completeness_pct?: number
correctness_pct?: number
phases?: Record<string, Phase>
banner_checks?: { violations?: BannerViolation[] }
category_tests?: CategoryTest[]
structured_checks?: StructuredCheck[]
summary?: Record<string, number>
}
}
const PHASE_LABEL: Record<string, string> = {
before_consent: 'Vor Consent',
after_reject: 'Nach Ablehnung',
after_accept: 'Nach Annahme',
}
const SEV_BADGE: Record<string, string> = {
CRITICAL: 'bg-red-600 text-white',
HIGH: 'bg-red-100 text-red-800',
MEDIUM: 'bg-amber-100 text-amber-800',
LOW: 'bg-blue-100 text-blue-800',
INFO: 'bg-gray-100 text-gray-600',
}
function pctColor(pct?: number): string {
if (pct === undefined || pct === null) return 'text-gray-400'
return pct >= 80 ? 'text-green-700' : pct >= 50 ? 'text-amber-700' : 'text-red-700'
}
export default function BannerTab({ checkId }: { checkId: string }) {
const [data, setData] = useState<BannerResp | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [checkFilter, setCheckFilter] = useState<'all' | 'fail' | 'critical'>('fail')
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/sdk/v1/agent/banner/${checkId}`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(e => { if (!cancelled) setError(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [checkId])
if (loading) return <div className="p-6 text-sm text-gray-500">Lade Banner-Daten</div>
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
if (!data?.found || !data.banner) {
return <div className="p-6 text-sm text-gray-500">Keine Banner-Daten zu diesem Check.</div>
}
const b = data.banner
const phases = b.phases || {}
const cats = b.category_tests || []
const violations = b.banner_checks?.violations || []
const checks = b.structured_checks || []
const summary = b.summary || {}
const filteredChecks = checks.filter(c => {
if (checkFilter === 'all') return true
if (checkFilter === 'fail') return !c.passed && !c.skipped
return !c.passed && !c.skipped && ['CRITICAL', 'HIGH'].includes(c.severity)
})
return (
<div className="space-y-6">
{/* Quality Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">Vollstaendigkeit</div>
<div className={`text-2xl font-semibold ${pctColor(b.completeness_pct)}`}>
{b.completeness_pct ?? ''}{b.completeness_pct !== undefined && '%'}
</div>
</div>
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">Korrektheit</div>
<div className={`text-2xl font-semibold ${pctColor(b.correctness_pct)}`}>
{b.correctness_pct ?? ''}{b.correctness_pct !== undefined && '%'}
</div>
</div>
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">Verstoesse</div>
<div className="text-2xl font-semibold text-red-700">
{summary.total_violations ?? violations.length}
</div>
<div className="text-[10px] text-gray-500 mt-1">
crit:{summary.critical ?? 0} · high:{summary.high ?? 0}
</div>
</div>
<div className="border rounded p-3">
<div className="text-[10px] uppercase text-gray-500">CMP</div>
<div className="text-sm font-medium text-gray-800 truncate">
{b.banner_provider || 'unbekannt'}
</div>
<div className="text-[10px] text-gray-500 mt-1">
{b.banner_detected ? 'Banner erkannt' : 'kein Banner'}
</div>
</div>
</div>
{/* Phases */}
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
Cookie-Setzungen pro Phase (echter Browser-Test)
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Phase</th>
<th className="px-3 py-2 text-center">Cookies</th>
<th className="px-3 py-2 text-center">Tracker</th>
<th className="px-3 py-2 text-left">Auffaelligkeiten</th>
</tr>
</thead>
<tbody>
{(['before_consent', 'after_reject', 'after_accept'] as const).map(key => {
const p = phases[key] || {}
const nc = (p.cookies || []).length
const nt = (p.tracking_services || []).length
const issues: string[] = []
if (p.violations?.length) issues.push(`${p.violations.length} Verstoss`)
if (p.new_tracking?.length) issues.push(`${p.new_tracking.length} neue Tracker`)
if (p.undocumented?.length) issues.push(`${p.undocumented.length} undokumentiert`)
const color = key === 'before_consent'
? (nc === 0 ? 'text-green-600' : 'text-red-600')
: key === 'after_reject'
? (nc <= 1 ? 'text-green-600' : 'text-amber-600')
: 'text-gray-700'
return (
<tr key={key} className="border-t">
<td className="px-3 py-2 font-medium">{PHASE_LABEL[key]}</td>
<td className={`px-3 py-2 text-center font-semibold ${color}`}>{nc}</td>
<td className="px-3 py-2 text-center">{nt}</td>
<td className="px-3 py-2 text-gray-500">{issues.join(', ') || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Per-Category */}
{cats.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
Provider-Listing pro Kategorie (P19 Click-Through-Test)
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Kategorie</th>
<th className="px-3 py-2 text-center">Anbieter sichtbar</th>
<th className="px-3 py-2 text-center">Tracker erkannt</th>
<th className="px-3 py-2 text-left">Violations</th>
</tr>
</thead>
<tbody>
{cats.map(c => {
const pdv = c.provider_details_visible
const pdv_label = pdv === true ? 'Ja' : pdv === false ? 'Nein' : ''
const pdv_color = pdv === false ? 'text-red-700' : pdv === true ? 'text-green-700' : 'text-gray-400'
return (
<tr key={c.category} className="border-t">
<td className="px-3 py-2">{c.category_label}</td>
<td className={`px-3 py-2 text-center font-semibold ${pdv_color}`}>{pdv_label}</td>
<td className="px-3 py-2 text-center">{(c.tracking_services || []).length}</td>
<td className="px-3 py-2 text-red-700 text-[10px]">
{(c.violations || []).map(v => v.text?.slice(0, 80)).join('; ') || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Banner-Checks Violations */}
{violations.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700">
Banner-Verstoesse ({violations.length})
</div>
<ul className="text-xs divide-y">
{violations.map((v, i) => {
const sev = (v.severity || 'MEDIUM').toUpperCase()
return (
<li key={i} className="px-3 py-2">
<div className="flex items-start gap-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[sev] || 'bg-gray-100'}`}>{sev}</span>
<div>
<div className="text-gray-900">{v.text}</div>
{v.legal_ref && <div className="text-[10px] text-gray-400 italic mt-1">Quelle: {v.legal_ref}</div>}
</div>
</div>
</li>
)
})}
</ul>
</div>
)}
{/* 46 structured_checks Drilldown */}
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b text-sm font-medium text-gray-700 flex items-center gap-3">
<span>Banner-Checks ({checks.length})</span>
<div className="ml-auto flex gap-1">
{(['all', 'fail', 'critical'] as const).map(f => (
<button key={f}
onClick={() => setCheckFilter(f)}
className={`px-2 py-1 rounded text-[10px] border ${
checkFilter === f ? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-600 border-gray-200'
}`}>
{f === 'all' ? 'Alle' : f === 'fail' ? 'Nur Fail' : 'Nur CRIT/HIGH'}
</button>
))}
</div>
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Sev</th>
<th className="px-3 py-2 text-left">Check</th>
</tr>
</thead>
<tbody>
{filteredChecks.map(c => (
<tr key={c.id} className="border-t">
<td className="px-3 py-2">
{c.passed ? <span className="text-green-600"></span>
: c.skipped ? <span className="text-gray-400"></span>
: <span className="text-red-600"></span>}
</td>
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${SEV_BADGE[c.severity] || 'bg-gray-100'}`}>
{c.severity}
</span>
</td>
<td className="px-3 py-2">
<div className="text-gray-900">{c.label}</div>
{c.hint && !c.passed && (
<div className="text-[10px] text-gray-500 mt-1">{c.hint.slice(0, 200)}</div>
)}
</td>
</tr>
))}
{filteredChecks.length === 0 && (
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400">Keine Checks fuer den Filter.</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -3,6 +3,7 @@
import React, { useEffect, useState, useMemo } from 'react'
import { use as useUnwrap } from 'react'
import FindingsTab from './FindingsTab'
import BannerTab from './BannerTab'
type MCRow = {
id: number
@@ -92,7 +93,7 @@ export default function AuditPage(
const [filterReg, setFilterReg] = useState<string>('')
const [filterDoc, setFilterDoc] = useState<string>('')
const [expanded, setExpanded] = useState<number | null>(null)
const [tab, setTab] = useState<'mc' | 'all'>('all')
const [tab, setTab] = useState<'mc' | 'all' | 'banner'>('all')
useEffect(() => {
let cancelled = false
@@ -155,6 +156,7 @@ export default function AuditPage(
<div className="flex gap-2 border-b border-gray-200">
{([
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
{ key: 'banner', label: 'Cookie-Banner-Analyse' },
{ key: 'mc', label: 'Nur MC-Scorecard' },
] as const).map(t => (
<button key={t.key}
@@ -168,6 +170,7 @@ export default function AuditPage(
</div>
{tab === 'all' && <FindingsTab checkId={checkId} />}
{tab === 'banner' && <BannerTab checkId={checkId} />}
{tab === 'mc' && <>
{/* Scorecard */}
@@ -614,6 +614,9 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
summary_html = build_management_summary(results)
scanned_html = build_scanned_urls_html(doc_entries)
providers_html = build_provider_list_html(banner_result, vvt_entries)
# P18: Deep-Block mit Phases + Quality-Score + Per-Category-Tracker
from .agent_doc_check_banner import build_banner_deep_html
banner_deep_html = build_banner_deep_html(banner_result)
vvt_html = build_vvt_table_html(cmp_vendors)
# MC scorecard aggregated across ALL docs in this run (DSGVO/TDDDG/
@@ -676,6 +679,20 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
site_name=site_name_for_exec,
)
# P18: Critical-Findings-Block (rot oben, mit Sofortmassnahmen +
# Quellen + Bussgeld-Praezedenz). Wird nur gerendert wenn echte
# kritische Verstoesse vorliegen.
critical_html = ""
try:
from .agent_doc_check_critical import build_critical_findings_html
critical_html = build_critical_findings_html(
banner_result=banner_result,
scorecard=scorecard,
results=results,
)
except Exception as e:
logger.warning("Critical-findings block skipped: %s", e)
# P10: Cookie-Policy-Architecture-Detection (BMW-Pattern erkennen)
cookie_arch_html = ""
try:
@@ -726,10 +743,10 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
# 7) providers_html + vvt_html (Vendor-Liste)
# 8) report_html (Doc-Pruefung Details)
full_html = (
exec_summary_html + cookie_arch_html + summary_html
+ scanned_html + profile_html
critical_html + exec_summary_html + cookie_arch_html
+ summary_html + scanned_html + profile_html
+ scorecard_html + redundancy_html
+ providers_html + vvt_html + report_html
+ providers_html + banner_deep_html + vvt_html + report_html
)
# Step 6: Send email — derive site name primarily from entered URL.
@@ -753,12 +770,23 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
"results": [_result_to_dict(r) for r in results],
"business_profile": profile_dict,
"extracted_profile": extracted_profile,
"banner_result": {
"detected": banner_result.get("banner_detected", False) if banner_result else False,
"provider": banner_result.get("banner_provider", "") if banner_result else "",
"violations": len(banner_result.get("banner_checks", {}).get("violations", [])) if banner_result else 0,
# P18: vollen consent-tester-Output durchreichen statt nur 4 Felder.
# phases (before/after-accept/reject) + banner_checks.violations +
# category_tests werden vom Renderer + Critical-Findings-Block genutzt.
"banner_result": ({
"detected": banner_result.get("banner_detected", False),
"provider": banner_result.get("banner_provider", ""),
"violations": len((banner_result.get("banner_checks") or {})
.get("violations", [])),
"tcf_vendor_count": len(tcf_vendors),
} if banner_result else None,
"completeness_pct": banner_result.get("completeness_pct"),
"correctness_pct": banner_result.get("correctness_pct"),
"phases": banner_result.get("phases", {}),
"banner_checks": banner_result.get("banner_checks", {}),
"category_tests": banner_result.get("category_tests", []),
"structured_checks": banner_result.get("structured_checks", []),
"summary": banner_result.get("summary", {}),
} if banner_result else None),
"tcf_vendors": vvt_entries if tcf_vendors else [],
"cmp_vendors": cmp_vendors,
"total_documents": len(results),
@@ -813,6 +841,7 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
check_id=check_id,
vendors=cmp_vendors,
profile=extracted_profile,
banner=banner_result,
)
# Unified findings (P5): bundle MC + Pflichtangaben + Vendor +
# Redundanz in one searchable table behind /agent/findings/<id>.
@@ -0,0 +1,206 @@
"""
P18 Erweiterter Banner-Block fuer die Email.
Rendert die Daten aus dem consent-tester die heute weggeworfen wurden:
- 3-Phasen-Cookie-Tabelle (before_consent / after_reject / after_accept)
- Banner-Quality-Score (completeness/correctness/violations)
- Per-Category-Tracker-Listing
- Violations-Liste mit Rechtsgrundlagen
"""
from __future__ import annotations
def _color_for(pct: int) -> str:
return ("#16a34a" if pct >= 80 else
"#d97706" if pct >= 50 else "#dc2626")
def _short_phase_label(key: str) -> str:
return {
"before_consent": "Vor Consent",
"after_reject": "Nach Ablehnung",
"after_accept": "Nach Annahme",
}.get(key, key)
def _phase_color(key: str, cookie_count: int) -> str:
if key == "before_consent":
return "#16a34a" if cookie_count == 0 else "#dc2626"
if key == "after_reject":
return "#16a34a" if cookie_count <= 1 else "#d97706"
return "#94a3b8"
def build_banner_deep_html(banner_result: dict | None) -> str:
"""Render: Banner-Quality + Phases + Violations.
Konsumiert das volle consent-tester-Response. Komplementiert
`build_provider_list_html` (das nur Summary + TCF-Vendor-Tabelle macht).
"""
if not banner_result:
return ""
parts: list[str] = [
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:700px;margin:0 auto 16px;padding:14px 18px;'
'background:#fff;border:1px solid #cbd5e1;border-radius:8px">'
'<h3 style="margin:0 0 12px;font-size:14px;color:#0f172a">'
'Cookie-Banner — technische Analyse</h3>'
]
# 1) Quality-Score-Cards
compl = banner_result.get("completeness_pct")
corr = banner_result.get("correctness_pct")
summary = banner_result.get("summary") or {}
n_critical = summary.get("critical", 0)
n_high = summary.get("high", 0)
if compl is not None or corr is not None:
parts.append(
'<table style="width:100%;border-collapse:separate;'
'border-spacing:6px;margin-bottom:10px"><tr>'
)
if compl is not None:
c = _color_for(int(compl))
parts.append(
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
f'border-radius:5px;border-left:3px solid {c}">'
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
f'Vollstaendigkeit</div>'
f'<div style="font-size:18px;font-weight:700;color:{c}">{compl}%</div>'
f'</td>'
)
if corr is not None:
c = _color_for(int(corr))
parts.append(
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
f'border-radius:5px;border-left:3px solid {c}">'
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
f'Korrektheit</div>'
f'<div style="font-size:18px;font-weight:700;color:{c}">{corr}%</div>'
f'</td>'
)
viol_c = ("#dc2626" if n_critical + n_high > 0 else
"#d97706" if (summary.get("total_violations") or 0) > 0 else
"#16a34a")
parts.append(
f'<td style="width:33%;padding:8px 10px;background:#f8fafc;'
f'border-radius:5px;border-left:3px solid {viol_c}">'
f'<div style="font-size:10px;color:#64748b;text-transform:uppercase">'
f'Verstoesse</div>'
f'<div style="font-size:18px;font-weight:700;color:{viol_c}">'
f'{summary.get("total_violations", 0)}'
f'<span style="font-size:11px;color:#64748b;margin-left:6px">'
f'(crit:{n_critical} high:{n_high})</span></div></td>'
)
parts.append('</tr></table>')
# 2) 3-Phasen-Tabelle
phases = banner_result.get("phases") or {}
if phases:
parts.append(
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
'font-weight:600">Cookie-Setzungen pro Phase '
'(echter Browser-Test):</div>'
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
'margin-bottom:10px;border:1px solid #e2e8f0">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Phase</th>'
'<th style="padding:5px 8px;text-align:center">Cookies</th>'
'<th style="padding:5px 8px;text-align:center">Tracker</th>'
'<th style="padding:5px 8px">Auffaelligkeiten</th>'
'</tr></thead><tbody>'
)
for key in ("before_consent", "after_reject", "after_accept"):
ph = phases.get(key) or {}
if not isinstance(ph, dict): continue
cookies = ph.get("cookies") or []
trackers = ph.get("tracking_services") or []
new_track = ph.get("new_tracking") or []
violations = ph.get("violations") or []
undoc = ph.get("undocumented") or []
color = _phase_color(key, len(cookies))
issues_parts = []
if violations: issues_parts.append(f"{len(violations)} Verstoss")
if new_track: issues_parts.append(f"{len(new_track)} neue Tracker")
if undoc: issues_parts.append(f"{len(undoc)} undokumentiert")
issues_str = ", ".join(issues_parts) or ""
parts.append(
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:5px 8px;color:#1e293b;font-weight:600">'
f'<span style="display:inline-block;width:6px;height:6px;'
f'border-radius:50%;background:{color};margin-right:6px"></span>'
f'{_short_phase_label(key)}</td>'
f'<td style="padding:5px 8px;text-align:center;color:{color};'
f'font-weight:600">{len(cookies)}</td>'
f'<td style="padding:5px 8px;text-align:center">{len(trackers)}</td>'
f'<td style="padding:5px 8px;color:#475569">{issues_str}</td>'
f'</tr>'
)
parts.append('</tbody></table>')
# 3) Per-Category-Tracker
cats = banner_result.get("category_tests") or []
if cats:
non_essential = [c for c in cats if c.get("category") != "necessary"]
if non_essential:
parts.append(
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
'font-weight:600">Provider-Listing pro Banner-Kategorie:</div>'
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
'margin-bottom:10px;border:1px solid #e2e8f0">'
'<thead><tr style="background:#f1f5f9;color:#475569;text-align:left">'
'<th style="padding:5px 8px">Kategorie</th>'
'<th style="padding:5px 8px;text-align:center">Anbieter</th>'
'<th style="padding:5px 8px">Hinweis</th>'
'</tr></thead><tbody>'
)
for c in non_essential:
n = len(c.get("tracking_services") or [])
label = c.get("category_label") or c.get("category", "?")
pdv = c.get("provider_details_visible")
# P19: echtes Signal aus Click-Through-Test
if pdv is False:
color, hint = "#dc2626", ("Banner zeigt KEINE Provider-"
"Details — keine informierte Einwilligung")
elif pdv is True:
color, hint = "#16a34a", ""
elif n == 0:
color, hint = "#d97706", ("Keine Anbieter erkannt (vermutlich "
"kein Provider-Listing im Banner)")
else:
color, hint = "#16a34a", ""
parts.append(
f'<tr style="border-top:1px solid #e2e8f0">'
f'<td style="padding:5px 8px">{label}</td>'
f'<td style="padding:5px 8px;text-align:center;color:{color};'
f'font-weight:600">{n}</td>'
f'<td style="padding:5px 8px;color:#dc2626;font-size:10px">'
f'{hint}</td></tr>'
)
parts.append('</tbody></table>')
# 4) Violations mit Rechtsgrundlage
violations = (banner_result.get("banner_checks") or {}).get("violations", [])
if violations:
parts.append(
'<div style="font-size:11px;color:#475569;margin:8px 0 4px;'
'font-weight:600">Erkannte Banner-Verstoesse:</div>'
'<ul style="margin:0 0 8px 18px;padding:0;font-size:11px;color:#1e293b">'
)
for v in violations[:8]:
sev = (v.get("severity") or "MEDIUM").upper()
sev_c = ("#dc2626" if sev in ("CRITICAL", "HIGH") else
"#d97706" if sev == "MEDIUM" else "#94a3b8")
parts.append(
f'<li style="margin-bottom:6px">'
f'<span style="display:inline-block;background:{sev_c};color:#fff;'
f'font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px">'
f'{sev}</span>{v.get("text", "")[:200]}'
f'<div style="font-size:10px;color:#94a3b8;margin-top:2px;'
f'font-style:italic">Quelle: {v.get("legal_ref", "")}</div></li>'
)
parts.append('</ul>')
parts.append('</div>')
return "".join(parts)
@@ -0,0 +1,217 @@
"""
P18 Critical-Findings-Block fuer die Executive-Summary.
Analysiert die echten Daten (banner_checks, phases, scorecard, results) und
rendert einen ROTEN Sofortmassnahmen-Block GANZ OBEN in der Email mit
Quellenangaben (DSK, EDPB, EuGH, Behoerden-Buessgeld-Faelle) und konkreten
Sofortmassnahmen.
Regel: Block wird nur gerendert wenn echte kritische Verstoesse vorliegen.
Bei sauberen Sites bleibt er weg.
"""
from __future__ import annotations
# Bekannte Buessgeld-Praezedenzfaelle als Quellen-Hint
_BUSSGELD_REFS = {
"no_provider_per_category": "CNIL France 2023 — TikTok 5 Mio EUR (fehlende Vendor-Transparenz)",
"dse_unvollstaendig": "BayLDA 2024 — diverse Mittelstand-Faelle, 5k50k EUR",
"cookie_doc_missing": "LfDI BW 2023 — fehlende Cookie-Erklaerung, 30k EUR",
"dark_pattern_reject": "EDPB Guidelines 3/2022 + DSK 2024 — Bussgeldrahmen Art. 83 DSGVO",
"schrems_ii": "EuGH C-311/18 (Schrems II) — Bussgeldrahmen bis 4% Konzern-Umsatz",
"impressum_im_banner": "LG Rostock 3 O 22/19 — Impressum-Pflicht ueberlagernder Banner",
}
def _detect_critical_issues(
banner_result: dict | None,
scorecard: dict | None,
results: list,
) -> list[dict]:
"""Erkenne kritische Verstoesse aus den vorliegenden Daten."""
issues: list[dict] = []
br = banner_result or {}
sc = scorecard or {}
# 1) Banner-Violations (HIGH/CRITICAL) aus consent-tester
for v in (br.get("banner_checks") or {}).get("violations", []):
sev = (v.get("severity") or "").upper()
if sev in ("CRITICAL", "HIGH"):
issues.append({
"key": "banner_violation",
"title": v.get("text", "")[:120],
"severity": sev,
"action": _action_for_banner_violation(v),
"source": v.get("legal_ref", ""),
"bussgeld": _BUSSGELD_REFS.get("impressum_im_banner")
if "impressum" in (v.get("text") or "").lower()
else _BUSSGELD_REFS.get("dark_pattern_reject"),
})
# 2) Category-Tests: Banner zeigt keine Provider-Details pro Kategorie.
# Bevorzugt das echte Signal aus dem Click-Through-Test (P19):
# provider_details_visible. Fallback: leere tracking_services.
cat_tests = br.get("category_tests") or []
cats_without_details = [
c for c in cat_tests
if c.get("category") != "necessary"
and (c.get("provider_details_visible") is False
or (c.get("provider_details_visible") is None
and not c.get("tracking_services")))
]
if cats_without_details and len(cat_tests) >= 2:
cats = ", ".join(c.get("category_label", c.get("category", "?"))
for c in cats_without_details)
issues.append({
"key": "no_provider_per_category",
"title": f"Cookie-Banner: Kategorien ({cats}) zeigen keine "
f"Provider-/Cookie-Details",
"severity": "HIGH",
"action": ("Pro Banner-Kategorie eine Liste der eingebundenen "
"Anbieter + Cookie-Details (Name, Zweck, Speicherdauer, "
"Drittlandtransfer) sichtbar machen — am besten als "
"ausklappbares Detail-Panel. Sonst ist die "
"Einwilligung nicht 'informiert' nach Art. 7 DSGVO "
"und gilt als unwirksam."),
"source": "Art. 7 Abs. 1 DSGVO, EDPB Guidelines 2/2023, DSK 2024",
"bussgeld": _BUSSGELD_REFS["no_provider_per_category"],
})
# 3) DSGVO/TDDDG-Score < 30%: DSE rechtswidrig
pct = int((sc.get("totals") or {}).get("pct", 100))
if pct and pct < 30:
issues.append({
"key": "dse_unvollstaendig",
"title": f"Datenschutzerklaerung erfuellt nur {pct}% der Pflichten",
"severity": "HIGH",
"action": ("Vollstaendig nach Art. 13 DSGVO ueberarbeiten: "
"Verantwortlicher, Zwecke, Rechtsgrundlage, "
"Speicherdauer, Drittland-Transfers, alle Betroffenen-"
"rechte, konkrete Aufsichtsbehoerde."),
"source": "Art. 13 DSGVO + Art. 14 (alternativ), DSK-OH Telemedien 2024",
"bussgeld": _BUSSGELD_REFS["dse_unvollstaendig"],
})
# 4) Cookie-Richtlinie fehlt komplett (nicht erreichbar)
cookie_missing = any(
(r.doc_type == "cookie" if hasattr(r, "doc_type") else
r.get("doc_type") == "cookie")
and ((r.error if hasattr(r, "error") else r.get("error", "")) or "")
.startswith("Auf der Website nicht gefunden")
for r in (results or [])
)
cookie_deduped = any(
(r.doc_type == "cookie" if hasattr(r, "doc_type") else
r.get("doc_type") == "cookie")
and "Nicht separat vorhanden" in
((r.error if hasattr(r, "error") else r.get("error", "")) or "")
for r in (results or [])
)
if cookie_missing or cookie_deduped:
issues.append({
"key": "cookie_doc_missing",
"title": ("Keine eigenstaendige Cookie-Richtlinie"
if cookie_deduped
else "Cookie-Richtlinie nicht auffindbar"),
"severity": "HIGH",
"action": ("Separate Cookie-Richtlinie-Seite erstellen mit "
"tabellarischer Auflistung aller Cookies (Name, "
"Anbieter, Zweck, Speicherdauer, Drittlandtransfer). "
"Direkt aus dem Banner verlinken."),
"source": "Art. 13 DSGVO, §25 TDDDG, DSK-OH Telemedien 2024",
"bussgeld": _BUSSGELD_REFS["cookie_doc_missing"],
})
# 5) Schrems-II-Risiko: Google/Meta/Microsoft im Banner, aber keine SCC/DPF
# Detection: pre-/post-consent-cookies in den phases enthalten US-Tracker
phases = br.get("phases") or {}
has_us_tracker = False
for ph in phases.values():
if not isinstance(ph, dict):
continue
for t in (ph.get("tracking_services") or []):
if isinstance(t, dict):
name = (t.get("name", "") or "").lower()
else:
name = str(t).lower()
if any(w in name for w in ("google", "meta", "facebook",
"microsoft", "linkedin", "tiktok")):
has_us_tracker = True
break
if has_us_tracker:
issues.append({
"key": "schrems_ii",
"title": "US-Tracker geladen — Schrems-II-Risiko",
"severity": "HIGH",
"action": ("Pro Drittland-Anbieter dokumentieren: SCC (Art. 46 "
"DSGVO) ODER DPF-Zertifizierung pruefen + in der "
"Datenschutzerklaerung explizit benennen."),
"source": "Art. 44 ff. DSGVO, EuGH C-311/18 (Schrems II)",
"bussgeld": _BUSSGELD_REFS["schrems_ii"],
})
return issues
def _action_for_banner_violation(v: dict) -> str:
text = (v.get("text") or "").lower()
if "impressum" in text:
return ("Impressum-Link direkt im Banner ergaenzen — bei "
"ueberlagerndem Banner Pflicht nach §5 TMG.")
if "ablehnen" in text or "dark pattern" in text:
return ("'Ablehnen'-Button visuell gleichwertig zu 'Akzeptieren' "
"gestalten (gleiche Groesse, Farbe, Position).")
if "widerruf" in text or "cookie-einstellungen" in text:
return ("Floating-Icon oder Footer-Link 'Cookie-Einstellungen' "
"permanent einblenden — Widerruf so einfach wie Erteilung.")
return ("Banner-Verstoss beheben gemaess der genannten Rechtsgrundlage.")
def build_critical_findings_html(
banner_result: dict | None,
scorecard: dict | None,
results: list,
) -> str:
"""Render der Critical-Findings-Box. Leerer String wenn keine Issues."""
issues = _detect_critical_issues(banner_result, scorecard, results)
if not issues:
return ""
items = []
for i in issues:
items.append(
f'<div style="margin-bottom:10px;padding:10px 12px;'
f'background:rgba(255,255,255,0.06);border-radius:4px;'
f'border-left:3px solid #fca5a5">'
f'<div style="font-size:13px;font-weight:700;color:#fff;'
f'margin-bottom:4px">'
f'<span style="display:inline-block;background:#dc2626;color:#fff;'
f'padding:1px 6px;border-radius:3px;font-size:9px;'
f'margin-right:6px">{i["severity"]}</span>{i["title"]}</div>'
f'<div style="font-size:11px;color:#fecaca;margin-top:4px">'
f'<strong>Sofortmassnahme:</strong> {i["action"]}</div>'
f'<div style="font-size:10px;color:#fca5a5;margin-top:4px;'
f'font-style:italic">Rechtsgrundlage: {i.get("source","")}'
+ (f' &middot; Praezedenz: {i["bussgeld"]}'
if i.get("bussgeld") else "") +
f'</div></div>'
)
n = len(issues)
return (
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
'max-width:700px;margin:0 auto 18px;padding:18px 22px;'
'background:#7f1d1d;border-radius:10px;color:white">'
'<div style="font-size:12px;color:#fecaca;text-transform:uppercase;'
'letter-spacing:1.5px;margin-bottom:6px;font-weight:700">'
'🚨 Sofortmassnahmen erforderlich</div>'
f'<h2 style="margin:0 0 10px;font-size:18px;color:white">'
f'{n} kritische Compliance-Risiken mit Bussgeldpotenzial</h2>'
'<p style="margin:0 0 12px;font-size:12px;color:#fecaca">'
'Die folgenden Verstoesse sind durch Tool-Analyse belegt und '
'erfordern Sofortmassnahmen. Bussgeldrahmen nach Art. 83 DSGVO: '
'<strong>bis 4% des weltweiten Jahresumsatzes</strong>.</p>'
+ "".join(items) +
'</div>'
)
@@ -241,7 +241,8 @@ def _check_to_action(doc_label: str, check_label: str, hint: str) -> str:
if "nicht im eingereichten text" in label_lower:
return (f"<strong>{doc_label}:</strong> Das eingereichte Dokument "
f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.")
if any(w in label_lower for w in ("rechtswidrig", "illegal", "haftungsausschluss", "disclaimer")):
return f"<strong>{doc_label}:</strong> '{check_label}' muss entfernt werden (Anti-Pattern, rechtlich wirkungslos)."
# Generic fallback
if hint and len(hint) < 150:
return f"<strong>{doc_label}:</strong> {hint[:120]}"
@@ -24,7 +24,7 @@ from compliance.services.unified_findings_store import (
findings_summary,
list_findings,
)
from compliance.services.compliance_audit_log import get_check_run
from compliance.services.compliance_audit_log import get_check_run, get_check_payload
logger = logging.getLogger(__name__)
@@ -102,3 +102,18 @@ def get_findings(
"count": 0,
"findings": [],
}
@router.get("/banner/{check_id}")
def get_banner_payload(check_id: str) -> dict:
"""P20: full banner_result (phases, structured_checks, category_tests,
banner_checks.violations) fuer das Voll-Audit-Frontend.
"""
try:
payload = get_check_payload(check_id) or {}
banner = payload.get("banner") or {}
return {"found": bool(banner), "check_id": check_id, "banner": banner}
except Exception as e:
logger.exception("get_banner_payload failed for %s", check_id)
return {"found": False, "check_id": check_id,
"error": str(e)[:200], "banner": {}}
@@ -66,27 +66,35 @@ def _ensure_db() -> None:
CREATE TABLE IF NOT EXISTS check_payloads (
check_id TEXT PRIMARY KEY,
vendors TEXT, -- JSON list[dict]
profile TEXT -- JSON dict
profile TEXT, -- JSON dict
banner TEXT -- P20: JSON dict full banner_result
);
""")
# P20 migration: spalte 'banner' nachtraeglich anlegen wenn alt
try:
conn.execute("ALTER TABLE check_payloads ADD COLUMN banner TEXT")
except sqlite3.OperationalError:
pass
def record_check_payload(
check_id: str,
vendors: list[dict] | None,
profile: dict | None,
banner: dict | None = None,
) -> None:
"""Persist cmp_vendors + extracted_profile for later migration use."""
"""Persist cmp_vendors + extracted_profile + banner_result (P20)."""
try:
_ensure_db()
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"INSERT OR REPLACE INTO check_payloads "
"(check_id, vendors, profile) VALUES (?, ?, ?)",
"(check_id, vendors, profile, banner) VALUES (?, ?, ?, ?)",
(
check_id,
json.dumps(vendors or [], ensure_ascii=False),
json.dumps(profile or {}, ensure_ascii=False),
json.dumps(banner or {}, ensure_ascii=False) if banner else None,
),
)
conn.commit()
@@ -95,13 +103,13 @@ def record_check_payload(
def get_check_payload(check_id: str) -> dict | None:
"""Load cmp_vendors + extracted_profile for a previous check."""
"""Load cmp_vendors + extracted_profile + banner_result for a previous check."""
try:
_ensure_db()
with sqlite3.connect(DB_PATH) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT vendors, profile FROM check_payloads WHERE check_id=?",
"SELECT vendors, profile, banner FROM check_payloads WHERE check_id=?",
(check_id,),
).fetchone()
if not row:
@@ -109,6 +117,7 @@ def get_check_payload(check_id: str) -> dict | None:
return {
"vendors": json.loads(row["vendors"] or "[]"),
"profile": json.loads(row["profile"] or "{}"),
"banner": json.loads(row["banner"]) if row["banner"] else None,
}
except Exception as e:
logger.warning("get_check_payload failed: %s", e)
+2
View File
@@ -124,6 +124,8 @@ async def scan_consent(req: ScanRequest):
"category_label": ct.category_label,
"tracking_services": ct.tracking_services,
"violations": ct.violations,
"provider_details_visible": getattr(ct, "provider_details_visible", False),
"cookies_set": ct.cookies_set,
} for ct in result.category_tests] if result.category_tests else [],
)
@@ -99,8 +99,69 @@ CMP_CATEGORY_CONFIG: dict[str, dict] = {
"marketing": "[data-purpose='advertising_purposes'] input, [data-purpose='ads'] input",
},
},
# P19: TYPO3 dp-cookieconsent (Dirk Persky) — basiert auf osano cookieconsent.
# Banner zeigt Checkboxes direkt; KEIN Settings-Modal, KEINE Provider-Details.
# Detection: Checkbox-IDs dp--cookie-*. Provider-/Cookie-Liste fehlt
# systematisch -> explizites Finding.
"dp-cookieconsent": {
"settings_button": None,
"save_button": "a.cc-allow:not(.cc-allow-all), button:has-text('Speichern')",
"categories": {
"statistics": "#dp--cookie-statistics",
"marketing": "#dp--cookie-marketing",
},
},
"Cookie Consent (Insites)": { # alias — banner_detector benennt dp-cookieconsent so
"settings_button": None,
"save_button": "a.cc-allow:not(.cc-allow-all), button:has-text('Speichern')",
"categories": {
"statistics": "#dp--cookie-statistics, input[id*='statistic' i]",
"marketing": "#dp--cookie-marketing, input[id*='marketing' i]",
},
},
}
# Selektoren um zu prueffen ob ein Banner Provider-/Cookie-Details
# nach Kategorie-Selektion ZEIGT (Per-Category-Vendor-Listing).
_PROVIDER_DETAIL_SELECTORS = (
"[class*='cookie-list' i]",
"[class*='cookielist' i]",
"[class*='vendor-list' i]",
"[class*='vendor_list' i]",
"[class*='provider-list' i]",
"[class*='cookie-detail' i]",
"[class*='vendor-detail' i]",
"[class*='cookie-item' i]",
"[class*='vendor-item' i]",
"table[class*='cookie' i]",
"table[class*='vendor' i]",
"ul[class*='cookie' i] li",
)
async def _provider_details_visible(page, category_label: str) -> bool:
"""True wenn im Banner sichtbare Provider-/Cookie-Details existieren.
Heuristik: irgendein Element matched die Detail-Selektoren UND ist visible.
Bei Banner wie dp-cookieconsent (kein Listing) immer False -> Finding.
"""
try:
return await page.evaluate(
"""(selectors) => {
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.width > 30 && r.height > 10) return true;
}
}
return false;
}""",
list(_PROVIDER_DETAIL_SELECTORS),
)
except Exception:
return False
# Generic category keywords for fallback detection
CATEGORY_KEYWORDS = {
"statistics": ["statistik", "analytics", "analyse", "statistics", "messung", "reichweite"],
@@ -125,6 +186,8 @@ class CategoryTestResult:
cookies_set: list[str] = field(default_factory=list)
tracking_services: list[str] = field(default_factory=list)
violations: list[dict] = field(default_factory=list)
# P19: Per-Category-Transparenz im Banner
provider_details_visible: bool = False
async def detect_categories(page: Page, banner: BannerInfo) -> list[CategoryInfo]:
@@ -242,6 +305,27 @@ async def test_single_category(
result.cookies_set = [c.get("name", "") for c in await context.cookies()]
result.tracking_services = find_tracking_services(result.scripts_loaded)
# P19: pruefe ob das Banner Provider-/Cookie-Details fuer diese
# Kategorie sichtbar macht — bei dp-cookieconsent (Safetykon) immer
# False -> kritischer Verstoss (Art. 7 DSGVO: keine informierte
# Einwilligung ohne Detail-Listing pro Kategorie).
result.provider_details_visible = await _provider_details_visible(
page, category.label,
)
if not result.provider_details_visible:
result.violations.append({
"service": "Cookie-Banner",
"severity": "HIGH",
"text": (f"Kategorie '{category.label}' zeigt keine "
f"Provider-/Cookie-Details im Banner — Nutzer "
f"kann nicht informiert einwilligen "
f"(Art. 7 Abs. 1 DSGVO)."),
"legal_ref": "Art. 7 Abs. 1 DSGVO, EDPB Guidelines 2/2023, "
"DSK-OH Telemedien 2024",
"expected_category": category.name,
"actual_category": category.name,
})
# Find violations: services that don't belong to this category
for service in result.tracking_services:
expected_cat = SERVICE_CATEGORY_MAP.get(service)