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
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>
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -841,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>.
|
||||
|
||||
@@ -158,13 +158,18 @@ def build_banner_deep_html(banner_result: dict | None) -> str:
|
||||
for c in non_essential:
|
||||
n = len(c.get("tracking_services") or [])
|
||||
label = c.get("category_label") or c.get("category", "?")
|
||||
if n == 0:
|
||||
color = "#dc2626"
|
||||
hint = ("Keine Anbieter sichtbar — Nutzer kann nicht "
|
||||
"informiert einwilligen (Art. 7 DSGVO)")
|
||||
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 = "#16a34a"
|
||||
hint = ""
|
||||
color, hint = "#16a34a", ""
|
||||
parts.append(
|
||||
f'<tr style="border-top:1px solid #e2e8f0">'
|
||||
f'<td style="padding:5px 8px">{label}</td>'
|
||||
|
||||
@@ -49,23 +49,32 @@ def _detect_critical_issues(
|
||||
else _BUSSGELD_REFS.get("dark_pattern_reject"),
|
||||
})
|
||||
|
||||
# 2) Category-Tests: leere Vendor-Liste pro Kategorie (Safetykon-Pattern)
|
||||
# 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 []
|
||||
empty_cats = [c for c in cat_tests
|
||||
if not c.get("tracking_services")
|
||||
and c.get("category") != "necessary"]
|
||||
if empty_cats and len(cat_tests) >= 2:
|
||||
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 empty_cats)
|
||||
for c in cats_without_details)
|
||||
issues.append({
|
||||
"key": "no_provider_per_category",
|
||||
"title": f"Cookie-Banner Kategorien ({cats}) ohne Provider-Listing",
|
||||
"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) ergaenzen. Sonst ist die "
|
||||
"Einwilligung nicht 'informiert' nach Art. 7 DSGVO."),
|
||||
"source": "Art. 7 DSGVO, EDPB Guidelines 2/2023, DSK 2024",
|
||||
"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"],
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user