Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 686834cea0 | |||
| 89af88ef7d | |||
| c4532049d8 |
@@ -1,19 +1,35 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { ChecklistView } from './ChecklistView'
|
||||||
|
|
||||||
|
interface CheckItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
passed: boolean
|
||||||
|
severity: string
|
||||||
|
matched_text: string
|
||||||
|
level?: number
|
||||||
|
parent?: string | null
|
||||||
|
skipped?: boolean
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface BannerResult {
|
interface BannerResult {
|
||||||
banner_detected: boolean
|
banner_detected: boolean
|
||||||
banner_provider: string
|
banner_provider: string
|
||||||
banner_text: string
|
|
||||||
banner_checks?: {
|
banner_checks?: {
|
||||||
violations: { code: string; text: string; severity: string }[]
|
violations: { code: string; text: string; severity: string }[]
|
||||||
passes: { code: string; text: string }[]
|
has_impressum_link?: boolean
|
||||||
|
has_dse_link?: boolean
|
||||||
}
|
}
|
||||||
|
structured_checks?: CheckItem[]
|
||||||
|
completeness_pct?: number
|
||||||
|
correctness_pct?: number
|
||||||
phases?: {
|
phases?: {
|
||||||
before_consent: { cookies: number; scripts: number; violations: string[] }
|
before_consent: { cookies: string[]; scripts: string[]; tracking_services: string[]; violations: any[] }
|
||||||
after_reject: { cookies: number; scripts: number; violations: string[] }
|
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
||||||
after_accept: { cookies: number; scripts: number; violations: string[] }
|
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +59,6 @@ export function BannerCheckTab() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (data.scan_id) {
|
if (data.scan_id) {
|
||||||
// Async polling
|
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
while (attempts < 60) {
|
while (attempts < 60) {
|
||||||
await new Promise(r => setTimeout(r, 3000))
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
@@ -69,9 +84,23 @@ export function BannerCheckTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const violations = result?.banner_checks?.violations || []
|
const structuredChecks = result?.structured_checks || []
|
||||||
const passes = result?.banner_checks?.passes || []
|
const hasStructured = structuredChecks.length > 0
|
||||||
const total = violations.length + passes.length
|
const compPct = result?.completeness_pct ?? 0
|
||||||
|
const corrPct = result?.correctness_pct ?? 0
|
||||||
|
|
||||||
|
// Build ChecklistView-compatible result for structured checks
|
||||||
|
const checklistResults = hasStructured ? [{
|
||||||
|
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
||||||
|
url: url,
|
||||||
|
doc_type: 'banner',
|
||||||
|
word_count: 0,
|
||||||
|
completeness_pct: compPct,
|
||||||
|
correctness_pct: corrPct,
|
||||||
|
checks: structuredChecks,
|
||||||
|
findings_count: structuredChecks.filter(c => !c.passed && !c.skipped).length,
|
||||||
|
error: '',
|
||||||
|
}] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -79,7 +108,7 @@ export function BannerCheckTab() {
|
|||||||
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
|
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
|
||||||
<p className="text-xs text-blue-700 mt-1">
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
|
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
|
||||||
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 20+ weitere Kriterien.
|
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 36 weitere Kriterien.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,14 +145,14 @@ export function BannerCheckTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* 3-Phase Summary Card */}
|
||||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
{result.phases && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
<div>
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`text-2xl`}>
|
<span className="text-2xl">
|
||||||
{result.banner_detected ? '🛡️' : '⚠️'}
|
{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
@@ -131,98 +160,50 @@ export function BannerCheckTab() {
|
|||||||
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
||||||
: 'Kein Cookie-Banner erkannt'}
|
: 'Kein Cookie-Banner erkannt'}
|
||||||
</h3>
|
</h3>
|
||||||
{total > 0 && (
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion
|
||||||
{passes.length}/{total} Pruefungen bestanden
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{total > 0 && (
|
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<PhaseBox
|
||||||
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
label="Vor Consent"
|
||||||
<div
|
icon="\uD83D\uDD12"
|
||||||
className={`h-full rounded-full ${violations.length === 0 ? 'bg-green-500' : violations.length <= 3 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
||||||
style={{ width: `${Math.round(passes.length / total * 100)}%` }}
|
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
||||||
/>
|
violations={result.phases.before_consent.violations?.length ?? 0}
|
||||||
</div>
|
/>
|
||||||
<span className={`text-xs font-medium ${violations.length === 0 ? 'text-green-700' : 'text-red-700'}`}>
|
<PhaseBox
|
||||||
{Math.round(passes.length / total * 100)}%
|
label="Nach Ablehnen"
|
||||||
</span>
|
icon="\uD83D\uDEAB"
|
||||||
</div>
|
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
||||||
)}
|
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
||||||
</div>
|
violations={result.phases.after_reject.violations?.length ?? 0}
|
||||||
</div>
|
/>
|
||||||
|
<PhaseBox
|
||||||
{/* 3-Phase Summary */}
|
label="Nach Akzeptieren"
|
||||||
{result.phases && (
|
icon="\u2705"
|
||||||
<div className="px-6 py-3 border-b border-gray-100 grid grid-cols-3 gap-4">
|
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
||||||
{[
|
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
||||||
{ label: 'Vor Consent', data: result.phases.before_consent, icon: '🔒' },
|
violations={0}
|
||||||
{ label: 'Nach Ablehnen', data: result.phases.after_reject, icon: '🚫' },
|
/>
|
||||||
{ label: 'Nach Akzeptieren', data: result.phases.after_accept, icon: '✅' },
|
|
||||||
].map(phase => (
|
|
||||||
<div key={phase.label} className="text-center">
|
|
||||||
<div className="text-lg">{phase.icon}</div>
|
|
||||||
<div className="text-xs font-medium text-gray-700">{phase.label}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
{phase.data.cookies} Cookies, {phase.data.scripts} Scripts
|
|
||||||
</div>
|
|
||||||
{phase.data.violations.length > 0 && (
|
|
||||||
<div className="text-xs text-red-600 font-medium">
|
|
||||||
{phase.data.violations.length} Verstoesse
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Violations */}
|
|
||||||
{violations.length > 0 && (
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<h4 className="text-xs font-semibold text-red-700 uppercase tracking-wide mb-2">
|
|
||||||
Verstoesse ({violations.length})
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{violations.map((v, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-red-700">{v.text}</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-0.5">{v.code} | {v.severity}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Passes */}
|
{/* Structured L1/L2 Checklist */}
|
||||||
{passes.length > 0 && (
|
{hasStructured && (
|
||||||
<div className="px-6 py-4 border-t border-gray-100">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
<h4 className="text-xs font-semibold text-green-700 uppercase tracking-wide mb-2">
|
<ChecklistView results={checklistResults} />
|
||||||
Bestanden ({passes.length})
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{passes.map((p, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<div className="text-sm text-gray-600">{p.text}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!result.banner_detected && violations.length === 0 && passes.length === 0 && (
|
{!result.banner_detected && !hasStructured && (
|
||||||
<div className="px-6 py-4 text-sm text-gray-500">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
|
<p className="text-sm text-gray-500">
|
||||||
|
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach ss25 TDDDG Pflicht.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,3 +211,22 @@ export function BannerCheckTab() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
||||||
|
label: string; icon: string; cookies: number; scripts: number; violations: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg">{icon}</div>
|
||||||
|
<div className="text-xs font-medium text-gray-700">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{cookies} Cookies, {scripts} Scripts
|
||||||
|
</div>
|
||||||
|
{violations > 0 && (
|
||||||
|
<div className="text-xs text-red-600 font-medium">
|
||||||
|
{violations} Verstoesse
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const DOC_TYPE_LABELS: Record<string, string> = {
|
|||||||
dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
|
dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
|
||||||
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
|
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
|
||||||
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
|
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
|
||||||
|
eu_institution: 'EU-Inst.', banner: 'Banner',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupedCheck {
|
interface GroupedCheck {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function MitigationsPage() {
|
|||||||
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||||
|
const [expandedMeasure, setExpandedMeasure] = useState<string | null>(null)
|
||||||
|
|
||||||
function toggleSection(type: string) {
|
function toggleSection(type: string) {
|
||||||
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||||
@@ -203,31 +204,45 @@ export default function MitigationsPage() {
|
|||||||
<div>Status</div>
|
<div>Status</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Rows — paginated */}
|
{/* Rows — paginated */}
|
||||||
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => (
|
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => {
|
||||||
<div key={m.id}
|
const isDetailOpen = expandedMeasure === m.id
|
||||||
className={`grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/)
|
||||||
<div className="pt-0.5">
|
const category = catMatch?.[1]
|
||||||
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
const refs = measureNorms[(m.title || '').toLowerCase()]
|
||||||
className="accent-purple-600" />
|
return (
|
||||||
</div>
|
<div key={m.id}>
|
||||||
<div className="min-w-0">
|
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
|
||||||
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
|
className={`grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||||
{m.description && <div className="text-xs text-gray-400 mt-0.5">{m.description}</div>}
|
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
{(() => {
|
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
||||||
const refs = measureNorms[(m.title || '').toLowerCase()]
|
className="accent-purple-600" />
|
||||||
return refs?.length > 0 ? (
|
</div>
|
||||||
<div className="text-[9px] text-blue-500 mt-0.5">Normen: {refs.join(', ')}</div>
|
<div className="min-w-0 flex items-start gap-1">
|
||||||
) : null
|
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isDetailOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
})()}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</div>
|
</svg>
|
||||||
<div className="text-xs text-gray-500">
|
<div>
|
||||||
{(m.linked_hazard_names || []).join(', ') || '-'}
|
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
|
||||||
</div>
|
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
|
||||||
<div>
|
</div>
|
||||||
<StatusBadge status={m.status} />
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{(m.linked_hazard_names || []).join(', ') || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<StatusBadge status={m.status} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isDetailOpen && (
|
||||||
|
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
|
||||||
|
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
|
||||||
|
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
|
||||||
|
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{items.length > (mitPages[type] || 1) * 50 && (
|
{items.length > (mitPages[type] || 1) * 50 && (
|
||||||
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
|
||||||
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface ProjectOverview {
|
|||||||
completeness_pct: number
|
completeness_pct: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
gates: Gate[]
|
metadata?: { limits_form?: Record<string, unknown> }
|
||||||
risk_summary?: {
|
risk_summary?: {
|
||||||
critical?: number
|
critical?: number
|
||||||
high?: number
|
high?: number
|
||||||
@@ -28,14 +28,6 @@ interface ProjectOverview {
|
|||||||
mitigation_count: number
|
mitigation_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Gate {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
passed: boolean | null
|
|
||||||
required: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ href: '/components', label: 'Komponenten verwalten', icon: 'cube', description: 'SW/FW/AI/HMI Baum bearbeiten' },
|
{ href: '/components', label: 'Komponenten verwalten', icon: 'cube', description: 'SW/FW/AI/HMI Baum bearbeiten' },
|
||||||
{ href: '/classification', label: 'Klassifikation pruefen', icon: 'tag', description: 'AI Act, MVO, CRA, NIS2' },
|
{ href: '/classification', label: 'Klassifikation pruefen', icon: 'tag', description: 'AI Act, MVO, CRA, NIS2' },
|
||||||
@@ -47,33 +39,6 @@ const QUICK_ACTIONS = [
|
|||||||
{ href: '/monitoring', label: 'Monitoring', icon: 'activity', description: 'Post-Market Ueberwachung' },
|
{ href: '/monitoring', label: 'Monitoring', icon: 'activity', description: 'Post-Market Ueberwachung' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function GateIndicator({ gate }: { gate: Gate }) {
|
|
||||||
const color = gate.passed === true
|
|
||||||
? 'bg-green-500'
|
|
||||||
: gate.passed === false
|
|
||||||
? 'bg-red-500'
|
|
||||||
: 'bg-gray-300'
|
|
||||||
|
|
||||||
const textColor = gate.passed === true
|
|
||||||
? 'text-green-700'
|
|
||||||
: gate.passed === false
|
|
||||||
? 'text-red-700'
|
|
||||||
: 'text-gray-500'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-2">
|
|
||||||
<div className={`w-3 h-3 rounded-full ${color} flex-shrink-0`} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className={`text-sm font-medium ${textColor}`}>{gate.name}</div>
|
|
||||||
<div className="text-xs text-gray-400">{gate.description}</div>
|
|
||||||
</div>
|
|
||||||
{gate.required && (
|
|
||||||
<span className="text-xs text-gray-400 flex-shrink-0">Pflicht</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RiskGauge({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
|
function RiskGauge({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
|
||||||
const pct = max > 0 ? Math.round((value / max) * 100) : 0
|
const pct = max > 0 ? Math.round((value / max) * 100) : 0
|
||||||
return (
|
return (
|
||||||
@@ -150,12 +115,15 @@ export default function ProjectOverviewPage() {
|
|||||||
mitCount = live.total_mitigations || 0
|
mitCount = live.total_mitigations || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate dynamic completeness percentage
|
// Calculate dynamic completeness percentage from CE process steps
|
||||||
const compCount = json.components?.length || 0
|
const compCount = json.components?.length || 0
|
||||||
const gates = (json.completeness_gates || json.gates || [])
|
const limitsForm = json.metadata?.limits_form || {}
|
||||||
const gatesPassed = gates.filter((g: Record<string, unknown>) => g.passed === true).length
|
const hasLimits = Object.keys(limitsForm).length > 0
|
||||||
const gatesTotal = gates.length || 1
|
const hasComponents = compCount > 0
|
||||||
const completeness = Math.round((gatesPassed / gatesTotal) * 100)
|
const hasHazards = hazCount > 0
|
||||||
|
const hasMitigations = mitCount > 0
|
||||||
|
const stepsComplete = [hasLimits, hasComponents, hasHazards, hasMitigations].filter(Boolean).length
|
||||||
|
const completeness = Math.round((stepsComplete / 6) * 100)
|
||||||
|
|
||||||
setProject({
|
setProject({
|
||||||
...json,
|
...json,
|
||||||
@@ -163,6 +131,7 @@ export default function ProjectOverviewPage() {
|
|||||||
component_count: compCount,
|
component_count: compCount,
|
||||||
hazard_count: hazCount,
|
hazard_count: hazCount,
|
||||||
mitigation_count: mitCount,
|
mitigation_count: mitCount,
|
||||||
|
metadata: json.metadata,
|
||||||
risk_summary: {
|
risk_summary: {
|
||||||
critical: rs.critical || 0,
|
critical: rs.critical || 0,
|
||||||
high: rs.high || 0,
|
high: rs.high || 0,
|
||||||
@@ -170,13 +139,6 @@ export default function ProjectOverviewPage() {
|
|||||||
low: rs.low || 0,
|
low: rs.low || 0,
|
||||||
total: rs.total || hazCount,
|
total: rs.total || hazCount,
|
||||||
},
|
},
|
||||||
gates: gates.map((g: Record<string, unknown>) => ({
|
|
||||||
id: g.id,
|
|
||||||
name: g.name || g.label || '',
|
|
||||||
description: g.description || g.details || '',
|
|
||||||
passed: g.passed,
|
|
||||||
required: g.required,
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch project:', err)
|
console.error('Failed to fetch project:', err)
|
||||||
@@ -327,15 +289,44 @@ export default function ProjectOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completeness Gates */}
|
{/* Progress Tracker */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Completeness Gates</h2>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="space-y-1">
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Projektfortschritt</h2>
|
||||||
{project.gates && project.gates.length > 0 ? (
|
<span className="text-sm font-bold text-purple-600">{project.completeness_pct}%</span>
|
||||||
project.gates.map((gate) => <GateIndicator key={gate.id} gate={gate} />)
|
</div>
|
||||||
) : (
|
<div className="w-full bg-gray-200 rounded-full h-2.5 mb-4">
|
||||||
<p className="text-sm text-gray-400">Keine Gates definiert</p>
|
<div className="bg-purple-500 h-2.5 rounded-full transition-all" style={{ width: `${project.completeness_pct}%` }} />
|
||||||
)}
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(() => {
|
||||||
|
const hasLimits = Object.keys(project.metadata?.limits_form || {}).length > 0
|
||||||
|
const steps = [
|
||||||
|
{ done: hasLimits, label: 'Grenzen definiert', detail: hasLimits ? 'Felder ausgefuellt' : 'ausstehend' },
|
||||||
|
{ done: project.component_count > 0, label: 'Komponenten erfasst', detail: `${project.component_count} Komponenten` },
|
||||||
|
{ done: project.hazard_count > 0, label: 'Gefaehrdungen identifiziert', detail: `${project.hazard_count} bewertet` },
|
||||||
|
{ done: project.mitigation_count > 0, label: 'Massnahmen zugeordnet', detail: `${project.mitigation_count} Massnahmen` },
|
||||||
|
{ done: false, label: 'Verifikation', detail: 'ausstehend' },
|
||||||
|
{ done: false, label: 'CE-Akte', detail: 'ausstehend' },
|
||||||
|
]
|
||||||
|
const firstPending = steps.find((s) => !s.done)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.label} className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm flex-shrink-0 ${step.done ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
{step.done ? '\u2713' : '\u25CB'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm flex-1 ${step.done ? 'text-gray-900 dark:text-white' : 'text-gray-400'}`}>{step.label}</span>
|
||||||
|
<span className="text-xs text-gray-400">{step.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
|
||||||
|
Naechster Schritt: {firstPending?.label || 'Alle Schritte abgeschlossen'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,21 +19,51 @@ interface TechFileSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
||||||
risk_assessment_report: {
|
// Annex IV mandatory sections (EU Machinery Regulation 2023/1230)
|
||||||
icon: '📊',
|
general_description: {
|
||||||
description: 'Zusammenfassung der Risikobeurteilung mit allen bewerteten Gefaehrdungen',
|
icon: '🏭',
|
||||||
|
description: 'Anhang IV.1 — Allgemeine Beschreibung der Maschine mit bestimmungsgemaesser Verwendung',
|
||||||
},
|
},
|
||||||
hazard_log: {
|
design_specifications: {
|
||||||
icon: '⚠️',
|
icon: '📐',
|
||||||
description: 'Vollstaendiges Gefaehrdungsprotokoll mit S/E/P-Bewertungen',
|
description: 'Anhang IV.2 — Gesamtplan, Schaltplaene und Systemarchitektur',
|
||||||
},
|
},
|
||||||
component_list: {
|
component_list: {
|
||||||
icon: '🔧',
|
icon: '🔧',
|
||||||
description: 'Verzeichnis aller sicherheitsrelevanten Komponenten',
|
description: 'Anhang IV.3 — Detailplaene und Verzeichnis aller sicherheitsrelevanten Komponenten',
|
||||||
},
|
},
|
||||||
classification_report: {
|
risk_assessment_report: {
|
||||||
|
icon: '📊',
|
||||||
|
description: 'Anhang IV.4 — Risikobeurteilung nach ISO 12100 mit allen bewerteten Gefaehrdungen',
|
||||||
|
},
|
||||||
|
standards_applied: {
|
||||||
|
icon: '📏',
|
||||||
|
description: 'Anhang IV.5 — Angewandte harmonisierte Normen und deren Vermutungswirkung',
|
||||||
|
},
|
||||||
|
test_reports: {
|
||||||
|
icon: '🧪',
|
||||||
|
description: 'Anhang IV.6 — Pruefberichte und Verifikationsergebnisse',
|
||||||
|
},
|
||||||
|
instructions_for_use: {
|
||||||
|
icon: '📖',
|
||||||
|
description: 'Anhang IV.7 — Betriebsanleitung mit Sicherheitshinweisen',
|
||||||
|
},
|
||||||
|
declaration_of_conformity: {
|
||||||
|
icon: '📜',
|
||||||
|
description: 'Anhang IV.8 — EU-Konformitaetserklaerung',
|
||||||
|
},
|
||||||
|
assembly_declaration: {
|
||||||
|
icon: '🔩',
|
||||||
|
description: 'Anhang IV.9 — Einbauerklaerung fuer unvollstaendige Maschinen',
|
||||||
|
},
|
||||||
|
// Supplementary CE-Akte sections
|
||||||
|
hazard_log_combined: {
|
||||||
|
icon: '⚠️',
|
||||||
|
description: 'Vollstaendiges Gefaehrdungsprotokoll (Hazard Log) mit S/E/P-Bewertungen',
|
||||||
|
},
|
||||||
|
essential_requirements: {
|
||||||
icon: '📋',
|
icon: '📋',
|
||||||
description: 'Regulatorische Klassifikation (AI Act, MVO, CRA, NIS2)',
|
description: 'Grundlegende Anforderungen (EHSR) nach MVO Anhang III',
|
||||||
},
|
},
|
||||||
mitigation_report: {
|
mitigation_report: {
|
||||||
icon: '🛡️',
|
icon: '🛡️',
|
||||||
@@ -47,17 +77,30 @@ const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
|||||||
icon: '📎',
|
icon: '📎',
|
||||||
description: 'Index aller Nachweisdokumente mit Verknuepfungen',
|
description: 'Index aller Nachweisdokumente mit Verknuepfungen',
|
||||||
},
|
},
|
||||||
declaration_of_conformity: {
|
classification_report: {
|
||||||
icon: '📜',
|
icon: '🏷️',
|
||||||
description: 'EU-Konformitaetserklaerung',
|
description: 'Regulatorische Klassifikation (AI Act, MVO, CRA, NIS2)',
|
||||||
},
|
|
||||||
instructions_for_use: {
|
|
||||||
icon: '📖',
|
|
||||||
description: 'Sicherheitshinweise fuer Betriebsanleitung',
|
|
||||||
},
|
},
|
||||||
monitoring_plan: {
|
monitoring_plan: {
|
||||||
icon: '📡',
|
icon: '📡',
|
||||||
description: 'Post-Market Surveillance Plan',
|
description: 'Post-Market Surveillance und Ueberwachungsplan',
|
||||||
|
},
|
||||||
|
// AI-specific sections (when AI components present)
|
||||||
|
ai_intended_purpose: {
|
||||||
|
icon: '🎯',
|
||||||
|
description: 'Bestimmungsgemaesser Zweck des KI-Systems (AI Act Art. 13)',
|
||||||
|
},
|
||||||
|
ai_model_description: {
|
||||||
|
icon: '🧠',
|
||||||
|
description: 'KI-Modellbeschreibung, Trainingsdaten und Architektur',
|
||||||
|
},
|
||||||
|
ai_risk_management: {
|
||||||
|
icon: '⚙️',
|
||||||
|
description: 'KI-Risikomanagementsystem (AI Act Art. 9)',
|
||||||
|
},
|
||||||
|
ai_human_oversight: {
|
||||||
|
icon: '👁️',
|
||||||
|
description: 'Menschliche Aufsicht und Kontrollmassnahmen (AI Act Art. 14)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,178 +158,3 @@ func (h *IACEHandler) VerifyMitigation(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "mitigation verified"})
|
c.JSON(http.StatusOK, gin.H{"message": "mitigation verified"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Evidence & Verification
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// UploadEvidence handles POST /projects/:id/evidence
|
|
||||||
// Creates a new evidence record for a project.
|
|
||||||
func (h *IACEHandler) UploadEvidence(c *gin.Context) {
|
|
||||||
projectID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
MitigationID *uuid.UUID `json:"mitigation_id,omitempty"`
|
|
||||||
VerificationPlanID *uuid.UUID `json:"verification_plan_id,omitempty"`
|
|
||||||
FileName string `json:"file_name" binding:"required"`
|
|
||||||
FilePath string `json:"file_path" binding:"required"`
|
|
||||||
FileHash string `json:"file_hash" binding:"required"`
|
|
||||||
FileSize int64 `json:"file_size" binding:"required"`
|
|
||||||
MimeType string `json:"mime_type" binding:"required"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := rbac.GetUserID(c)
|
|
||||||
|
|
||||||
evidence := &iace.Evidence{
|
|
||||||
ProjectID: projectID,
|
|
||||||
MitigationID: req.MitigationID,
|
|
||||||
VerificationPlanID: req.VerificationPlanID,
|
|
||||||
FileName: req.FileName,
|
|
||||||
FilePath: req.FilePath,
|
|
||||||
FileHash: req.FileHash,
|
|
||||||
FileSize: req.FileSize,
|
|
||||||
MimeType: req.MimeType,
|
|
||||||
Description: req.Description,
|
|
||||||
UploadedBy: userID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.CreateEvidence(c.Request.Context(), evidence); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit trail
|
|
||||||
newVals, _ := json.Marshal(evidence)
|
|
||||||
h.store.AddAuditEntry(
|
|
||||||
c.Request.Context(), projectID, "evidence", evidence.ID,
|
|
||||||
iace.AuditActionCreate, userID.String(), nil, newVals,
|
|
||||||
)
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"evidence": evidence})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListEvidence handles GET /projects/:id/evidence
|
|
||||||
// Lists all evidence records for a project.
|
|
||||||
func (h *IACEHandler) ListEvidence(c *gin.Context) {
|
|
||||||
projectID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
evidence, err := h.store.ListEvidence(c.Request.Context(), projectID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if evidence == nil {
|
|
||||||
evidence = []iace.Evidence{}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"evidence": evidence,
|
|
||||||
"total": len(evidence),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateVerificationPlan handles POST /projects/:id/verification-plan
|
|
||||||
// Creates a new verification plan for a project.
|
|
||||||
func (h *IACEHandler) CreateVerificationPlan(c *gin.Context) {
|
|
||||||
projectID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req iace.CreateVerificationPlanRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override project ID from URL path
|
|
||||||
req.ProjectID = projectID
|
|
||||||
|
|
||||||
plan, err := h.store.CreateVerificationPlan(c.Request.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit trail
|
|
||||||
userID := rbac.GetUserID(c)
|
|
||||||
newVals, _ := json.Marshal(plan)
|
|
||||||
h.store.AddAuditEntry(
|
|
||||||
c.Request.Context(), projectID, "verification_plan", plan.ID,
|
|
||||||
iace.AuditActionCreate, userID.String(), nil, newVals,
|
|
||||||
)
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"verification_plan": plan})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateVerificationPlan handles PUT /verification-plan/:vid
|
|
||||||
// Updates a verification plan with the provided fields.
|
|
||||||
func (h *IACEHandler) UpdateVerificationPlan(c *gin.Context) {
|
|
||||||
planID, err := uuid.Parse(c.Param("vid"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification plan ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var updates map[string]interface{}
|
|
||||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := h.store.UpdateVerificationPlan(c.Request.Context(), planID, updates)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if plan == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "verification plan not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"verification_plan": plan})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteVerification handles POST /verification-plan/:vid/complete
|
|
||||||
// Marks a verification plan as completed with a result.
|
|
||||||
func (h *IACEHandler) CompleteVerification(c *gin.Context) {
|
|
||||||
planID, err := uuid.Parse(c.Param("vid"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification plan ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Result string `json:"result" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := rbac.GetUserID(c)
|
|
||||||
|
|
||||||
if err := h.store.CompleteVerification(
|
|
||||||
c.Request.Context(), planID, req.Result, userID.String(),
|
|
||||||
); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "verification completed"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,19 +35,30 @@ func (h *IACEHandler) GenerateTechFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the standard CE technical file sections to generate
|
// Define sections per EU Machinery Regulation 2023/1230 Annex IV structure.
|
||||||
|
// Core Annex IV sections come first, then supplementary CE-Akte sections.
|
||||||
sectionDefinitions := []struct {
|
sectionDefinitions := []struct {
|
||||||
SectionType string
|
SectionType string
|
||||||
Title string
|
Title string
|
||||||
}{
|
}{
|
||||||
{"general_description", "General Description of the Machinery"},
|
// Annex IV mandatory sections
|
||||||
{"risk_assessment_report", "Risk Assessment Report"},
|
{"general_description", "Anhang IV.1 — Allgemeine Beschreibung der Maschine"},
|
||||||
{"hazard_log_combined", "Combined Hazard Log"},
|
{"design_specifications", "Anhang IV.2 — Gesamtplan und Schaltplaene"},
|
||||||
{"essential_requirements", "Essential Health and Safety Requirements"},
|
{"component_list", "Anhang IV.3 — Detailplaene und Komponentenliste"},
|
||||||
{"design_specifications", "Design Specifications and Drawings"},
|
{"risk_assessment_report", "Anhang IV.4 — Risikobeurteilung"},
|
||||||
{"test_reports", "Test Reports and Verification Results"},
|
{"standards_applied", "Anhang IV.5 — Angewandte harmonisierte Normen"},
|
||||||
{"standards_applied", "Applied Harmonised Standards"},
|
{"test_reports", "Anhang IV.6 — Pruefberichte und Ergebnisse"},
|
||||||
{"declaration_of_conformity", "EU Declaration of Conformity"},
|
{"instructions_for_use", "Anhang IV.7 — Betriebsanleitung"},
|
||||||
|
{"declaration_of_conformity", "Anhang IV.8 — EU-Konformitaetserklaerung"},
|
||||||
|
{"assembly_declaration", "Anhang IV.9 — Einbauerklaerung (falls zutreffend)"},
|
||||||
|
// Supplementary CE-Akte sections
|
||||||
|
{"hazard_log_combined", "Gefaehrdungsprotokoll (Hazard Log)"},
|
||||||
|
{"essential_requirements", "Grundlegende Anforderungen (EHSR)"},
|
||||||
|
{"mitigation_report", "Massnahmenbericht (3-Stufen-Verfahren)"},
|
||||||
|
{"verification_report", "Verifikationsbericht"},
|
||||||
|
{"evidence_index", "Nachweisverzeichnis"},
|
||||||
|
{"classification_report", "Regulatorischer Klassifizierungsbericht"},
|
||||||
|
{"monitoring_plan", "Post-Market-Monitoring-Plan"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project has AI components for additional sections
|
// Check if project has AI components for additional sections
|
||||||
@@ -184,6 +195,7 @@ func (h *IACEHandler) GenerateSingleSection(c *gin.Context) {
|
|||||||
"evidence_index": "Evidence Index",
|
"evidence_index": "Evidence Index",
|
||||||
"instructions_for_use": "Instructions for Use",
|
"instructions_for_use": "Instructions for Use",
|
||||||
"monitoring_plan": "Post-Market Monitoring Plan",
|
"monitoring_plan": "Post-Market Monitoring Plan",
|
||||||
|
"assembly_declaration": "Anhang IV.9 — Einbauerklaerung (falls zutreffend)",
|
||||||
"ai_intended_purpose": "AI System Intended Purpose",
|
"ai_intended_purpose": "AI System Intended Purpose",
|
||||||
"ai_model_description": "AI Model Description and Training Data",
|
"ai_model_description": "AI Model Description and Training Data",
|
||||||
"ai_risk_management": "AI Risk Management System",
|
"ai_risk_management": "AI Risk Management System",
|
||||||
|
|||||||
@@ -403,6 +403,10 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
|||||||
iaceRoutes.POST("/projects/:id/verification-plan", h.CreateVerificationPlan)
|
iaceRoutes.POST("/projects/:id/verification-plan", h.CreateVerificationPlan)
|
||||||
iaceRoutes.PUT("/verification-plan/:vid", h.UpdateVerificationPlan)
|
iaceRoutes.PUT("/verification-plan/:vid", h.UpdateVerificationPlan)
|
||||||
iaceRoutes.POST("/verification-plan/:vid/complete", h.CompleteVerification)
|
iaceRoutes.POST("/verification-plan/:vid/complete", h.CompleteVerification)
|
||||||
|
iaceRoutes.GET("/projects/:id/verifications", h.ListVerificationPlans)
|
||||||
|
iaceRoutes.POST("/projects/:id/verifications", h.CreateVerificationAlias)
|
||||||
|
iaceRoutes.DELETE("/projects/:id/verifications/:vid", h.DeleteVerificationPlan)
|
||||||
|
iaceRoutes.POST("/projects/:id/verifications/:vid/complete", h.CompleteVerificationAlias)
|
||||||
iaceRoutes.POST("/projects/:id/tech-file/generate", h.GenerateTechFile)
|
iaceRoutes.POST("/projects/:id/tech-file/generate", h.GenerateTechFile)
|
||||||
iaceRoutes.GET("/projects/:id/tech-file", h.ListTechFileSections)
|
iaceRoutes.GET("/projects/:id/tech-file", h.ListTechFileSections)
|
||||||
iaceRoutes.PUT("/projects/:id/tech-file/:section", h.UpdateTechFileSection)
|
iaceRoutes.PUT("/projects/:id/tech-file/:section", h.UpdateTechFileSection)
|
||||||
|
|||||||
@@ -247,6 +247,15 @@ func (s *Store) getVerificationPlan(ctx context.Context, id uuid.UUID) (*Verific
|
|||||||
return &vp, nil
|
return &vp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteVerificationPlan deletes a verification plan by ID
|
||||||
|
func (s *Store) DeleteVerificationPlan(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `DELETE FROM iace_verification_plans WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete verification plan: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Reference Data Operations
|
// Reference Data Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -367,12 +367,19 @@ func (s *Store) GetRiskSummary(ctx context.Context, projectID uuid.UUID) (*RiskS
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch: get all latest assessments in one query
|
||||||
|
assessmentMap, err := s.GetLatestAssessmentsByProject(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get risk summary - batch assessments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
highestRisk := RiskLevelNegligible
|
highestRisk := RiskLevelNegligible
|
||||||
|
|
||||||
for _, h := range hazards {
|
for _, h := range hazards {
|
||||||
latest, err := s.GetLatestAssessment(ctx, h.ID)
|
ra, ok := assessmentMap[h.ID]
|
||||||
if err != nil {
|
var latest *RiskAssessment
|
||||||
return nil, fmt.Errorf("get risk summary - get assessment for hazard %s: %w", h.ID, err)
|
if ok {
|
||||||
|
latest = &ra
|
||||||
}
|
}
|
||||||
if latest == nil {
|
if latest == nil {
|
||||||
// Hazard without assessment counts as unassessed; consider it not acceptable
|
// Hazard without assessment counts as unassessed; consider it not acceptable
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
SectionEvidenceIndex = "evidence_index"
|
SectionEvidenceIndex = "evidence_index"
|
||||||
SectionInstructionsForUse = "instructions_for_use"
|
SectionInstructionsForUse = "instructions_for_use"
|
||||||
SectionMonitoringPlan = "monitoring_plan"
|
SectionMonitoringPlan = "monitoring_plan"
|
||||||
|
SectionAssemblyDeclaration = "assembly_declaration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -68,6 +69,8 @@ var sectionSystemPrompts = map[string]string{
|
|||||||
SectionInstructionsForUse: `Erstelle eine Gliederung fuer die Betriebsanleitung gemaess EU-Maschinenverordnung 2023/1230 Anhang III Abschnitt 1.7.4. Enthalten: 1) Bestimmungsgemaesse Verwendung, 2) Inbetriebnahme, 3) Sicherer Betrieb, 4) Wartung, 5) Restrisiken und Warnhinweise, 6) Ausserbetriebnahme. Beruecksichtige identifizierte Gefaehrdungen.`,
|
SectionInstructionsForUse: `Erstelle eine Gliederung fuer die Betriebsanleitung gemaess EU-Maschinenverordnung 2023/1230 Anhang III Abschnitt 1.7.4. Enthalten: 1) Bestimmungsgemaesse Verwendung, 2) Inbetriebnahme, 3) Sicherer Betrieb, 4) Wartung, 5) Restrisiken und Warnhinweise, 6) Ausserbetriebnahme. Beruecksichtige identifizierte Gefaehrdungen.`,
|
||||||
|
|
||||||
SectionMonitoringPlan: `Erstelle einen Post-Market-Monitoring-Plan fuer das Produkt. Enthalten: 1) Ueberwachungsziele, 2) Datenquellen (Kundenfeedback, Vorfaelle, Updates), 3) Ueberwachungsintervalle, 4) Eskalationsverfahren, 5) Dokumentationspflichten, 6) Verantwortlichkeiten. Beruecksichtige AI Act Art. 72 (Post-Market Monitoring) falls KI-Komponenten vorhanden.`,
|
SectionMonitoringPlan: `Erstelle einen Post-Market-Monitoring-Plan fuer das Produkt. Enthalten: 1) Ueberwachungsziele, 2) Datenquellen (Kundenfeedback, Vorfaelle, Updates), 3) Ueberwachungsintervalle, 4) Eskalationsverfahren, 5) Dokumentationspflichten, 6) Verantwortlichkeiten. Beruecksichtige AI Act Art. 72 (Post-Market Monitoring) falls KI-Komponenten vorhanden.`,
|
||||||
|
|
||||||
|
SectionAssemblyDeclaration: `Erstelle eine Einbauerklaerung gemaess EU-Maschinenverordnung 2023/1230 Anhang IV.9 fuer eine unvollstaendige Maschine. Enthalten: 1) Hersteller-Angaben, 2) Bezeichnung der unvollstaendigen Maschine, 3) Erklaerung, dass die Inbetriebnahme untersagt ist bis die Gesamtmaschine konform erklaert wurde, 4) Liste der eingehaltenen Anforderungen, 5) Angaben zur technischen Dokumentation. Falls die Maschine vollstaendig ist, vermerke "Nicht zutreffend — vollstaendige Maschine".`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -95,6 +98,7 @@ func buildRAGQuery(sectionType string) string {
|
|||||||
SectionEvidenceIndex: "Nachweisdokumente Evidence Konformitaetsnachweis Dokumentenindex",
|
SectionEvidenceIndex: "Nachweisdokumente Evidence Konformitaetsnachweis Dokumentenindex",
|
||||||
SectionInstructionsForUse: "Betriebsanleitung Benutzerinformation Maschinenverordnung Abschnitt 1.7.4 Sicherheitshinweise",
|
SectionInstructionsForUse: "Betriebsanleitung Benutzerinformation Maschinenverordnung Abschnitt 1.7.4 Sicherheitshinweise",
|
||||||
SectionMonitoringPlan: "Post-Market-Monitoring Ueberwachungsplan AI Act Art. 72 Marktbeobachtung",
|
SectionMonitoringPlan: "Post-Market-Monitoring Ueberwachungsplan AI Act Art. 72 Marktbeobachtung",
|
||||||
|
SectionAssemblyDeclaration: "Einbauerklaerung unvollstaendige Maschine Maschinenverordnung Anhang IV",
|
||||||
}
|
}
|
||||||
|
|
||||||
if q, ok := ragQueries[sectionType]; ok {
|
if q, ok := ragQueries[sectionType]; ok {
|
||||||
@@ -133,6 +137,7 @@ func sectionDisplayName(sectionType string) string {
|
|||||||
SectionEvidenceIndex: "Nachweisverzeichnis",
|
SectionEvidenceIndex: "Nachweisverzeichnis",
|
||||||
SectionInstructionsForUse: "Betriebsanleitung (Gliederung)",
|
SectionInstructionsForUse: "Betriebsanleitung (Gliederung)",
|
||||||
SectionMonitoringPlan: "Post-Market-Monitoring-Plan",
|
SectionMonitoringPlan: "Post-Market-Monitoring-Plan",
|
||||||
|
SectionAssemblyDeclaration: "Einbauerklaerung",
|
||||||
}
|
}
|
||||||
if name, ok := names[sectionType]; ok {
|
if name, ok := names[sectionType]; ok {
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ SECTION_TYPE_MAP = [
|
|||||||
(r"datenschutzfolge|dsfa|risikoanalyse", "dsfa"),
|
(r"datenschutzfolge|dsfa|risikoanalyse", "dsfa"),
|
||||||
(r"^social\s*media$|^soziale\s+(?:medien|netzwerke)$", "social_media"),
|
(r"^social\s*media$|^soziale\s+(?:medien|netzwerke)$", "social_media"),
|
||||||
(r"datenschutzerkl(?:ae|ä)rung.*social|datenschutz\s+f(?:ue|ü)r\s+social", "social_media"),
|
(r"datenschutzerkl(?:ae|ä)rung.*social|datenschutz\s+f(?:ue|ü)r\s+social", "social_media"),
|
||||||
|
(r"(?:verordnung|regulation)\s*\(?eu\)?\s*2018\s*/?\s*1725", "eu_institution"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
doc_checks — Legal document compliance checkers.
|
doc_checks — Legal document compliance checkers.
|
||||||
|
|
||||||
Provides checklists and functions for verifying legal documents
|
Provides checklists and functions for verifying legal documents
|
||||||
(DSI, AGB, Impressum, Cookie, Widerruf, Social Media, DSFA)
|
(DSI, AGB, Impressum, Cookie, Widerruf, Social Media, DSFA, EU Institution)
|
||||||
against their mandatory content requirements.
|
against their mandatory content requirements.
|
||||||
|
|
||||||
Two check levels:
|
Two check levels:
|
||||||
@@ -18,6 +18,7 @@ from .impressum_checks import IMPRESSUM_CHECKLIST
|
|||||||
from .cookie_checks import COOKIE_CHECKLIST
|
from .cookie_checks import COOKIE_CHECKLIST
|
||||||
from .social_media_checks import JOINT_CONTROLLER_CHECKLIST
|
from .social_media_checks import JOINT_CONTROLLER_CHECKLIST
|
||||||
from .dsfa_checks import DSFA_CHECKLIST
|
from .dsfa_checks import DSFA_CHECKLIST
|
||||||
|
from .eu_institution_checks import EU_INSTITUTION_CHECKLIST
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"check_document_completeness",
|
"check_document_completeness",
|
||||||
@@ -29,4 +30,5 @@ __all__ = [
|
|||||||
"COOKIE_CHECKLIST",
|
"COOKIE_CHECKLIST",
|
||||||
"JOINT_CONTROLLER_CHECKLIST",
|
"JOINT_CONTROLLER_CHECKLIST",
|
||||||
"DSFA_CHECKLIST",
|
"DSFA_CHECKLIST",
|
||||||
|
"EU_INSTITUTION_CHECKLIST",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ ART13_CHECKLIST = [
|
|||||||
"label": "Telefonnummer des Verantwortlichen",
|
"label": "Telefonnummer des Verantwortlichen",
|
||||||
"level": 2, "parent": "controller",
|
"level": 2, "parent": "controller",
|
||||||
"patterns": [
|
"patterns": [
|
||||||
r"(?:tel(?:efon)?|phone|fon)\s*[.:]\s*[\+\d][\d\s/\-]{6,}",
|
r"(?:tel(?:efon)?|phone|fon)\s*[.:]\s*[\+\d][\d\s/\-\(\)]{6,}",
|
||||||
r"\+49\s*[\d\s/\-]{8,}",
|
r"\+49\s*[\d\s/\-\(\)]{8,}",
|
||||||
|
r"0\d{2,4}\s*[\(/\-\s]\s*\d{3,}",
|
||||||
],
|
],
|
||||||
"severity": "MEDIUM",
|
"severity": "MEDIUM",
|
||||||
"hint": "EuGH (C-298/17, 'Verein fuer Konsumenteninformation') verlangt effektive Kontaktmoeglichkeit. Telefon ist nicht zwingend, aber empfohlen — fehlt sie, muss ein gleichwertiger Kanal (z.B. Chat, Rueckruf) angeboten werden.",
|
"hint": "EuGH (C-298/17, 'Verein fuer Konsumenteninformation') verlangt effektive Kontaktmoeglichkeit. Telefon ist nicht zwingend, aber empfohlen — fehlt sie, muss ein gleichwertiger Kanal (z.B. Chat, Rueckruf) angeboten werden.",
|
||||||
@@ -345,7 +346,7 @@ ART13_CHECKLIST = [
|
|||||||
"id": "rights_art21",
|
"id": "rights_art21",
|
||||||
"label": "Widerspruchsrecht (Art. 21)",
|
"label": "Widerspruchsrecht (Art. 21)",
|
||||||
"level": 2, "parent": "rights",
|
"level": 2, "parent": "rights",
|
||||||
"patterns": [r"art\.\s*21", r"widerspruchsrecht", r"right\s+to\s+object"],
|
"patterns": [r"art\.\s*21", r"widerspruchsrecht", r"recht\s+auf\s+widerspruch", r"§\s*23\s+kdg", r"right\s+to\s+object"],
|
||||||
"severity": "LOW",
|
"severity": "LOW",
|
||||||
"hint": "Art. 21(4) DSGVO: Der Widerspruchshinweis muss spaetestens zum Zeitpunkt der ersten Kommunikation GESONDERT und in klarer Sprache erfolgen. Haeufiger Fehler: Widerspruchsrecht nur im Fliesstext versteckt — eigener Abschnitt/Hervorhebung noetig.",
|
"hint": "Art. 21(4) DSGVO: Der Widerspruchshinweis muss spaetestens zum Zeitpunkt der ersten Kommunikation GESONDERT und in klarer Sprache erfolgen. Haeufiger Fehler: Widerspruchsrecht nur im Fliesstext versteckt — eigener Abschnitt/Hervorhebung noetig.",
|
||||||
},
|
},
|
||||||
@@ -386,6 +387,9 @@ ART13_CHECKLIST = [
|
|||||||
r"l(?:an)?fdi\s+\w+",
|
r"l(?:an)?fdi\s+\w+",
|
||||||
r"bfdi",
|
r"bfdi",
|
||||||
r"(?:bayerische|hessische|s(?:ae|ä)chsische|berliner)\s+(?:datenschutz|aufsicht)",
|
r"(?:bayerische|hessische|s(?:ae|ä)chsische|berliner)\s+(?:datenschutz|aufsicht)",
|
||||||
|
r"(?:katholisch|evangelisch|kirchlich)\w*\s+datenschutz",
|
||||||
|
r"datenschutzzentrum",
|
||||||
|
r"kd(?:oe|ö)r",
|
||||||
],
|
],
|
||||||
"severity": "LOW",
|
"severity": "LOW",
|
||||||
"hint": "Vollstaendigen Namen, Adresse und Website der Aufsichtsbehoerde angeben. Haeufiger Fehler: 'die zustaendige Aufsichtsbehoerde' ohne Konkretisierung. Korrekt z.B.: 'LfDI BW, Koenigstrasse 10a, 70173 Stuttgart, www.baden-wuerttemberg.datenschutz.de'.",
|
"hint": "Vollstaendigen Namen, Adresse und Website der Aufsichtsbehoerde angeben. Haeufiger Fehler: 'die zustaendige Aufsichtsbehoerde' ohne Konkretisierung. Korrekt z.B.: 'LfDI BW, Koenigstrasse 10a, 70173 Stuttgart, www.baden-wuerttemberg.datenschutz.de'.",
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
"""
|
||||||
|
EU Institution checks — Verordnung (EU) 2018/1725.
|
||||||
|
Applies to EU institutions, bodies, offices and agencies instead of DSGVO.
|
||||||
|
Key differences: Art. 15 (not 13), Art. 5 (not 6), EDSB (not national DPAs).
|
||||||
|
L1: Pflichtangabe erwaehnt? L2: Pflichtangabe korrekt/vollstaendig?
|
||||||
|
"""
|
||||||
|
|
||||||
|
EU_INSTITUTION_CHECKLIST = [
|
||||||
|
# == L1: Verantwortlicher (Controller) =================================
|
||||||
|
{
|
||||||
|
"id": "eu_controller",
|
||||||
|
"label": "Verantwortlicher (Art. 15(1)(a) VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"verantwortlich\w*\s+(?:ist|im sinne|fuer|f(?:ue|ü)r)",
|
||||||
|
r"kontaktdaten\s+des\s+verantwortlichen",
|
||||||
|
r"name\s+(?:und|&)\s+kontaktdaten\s+des",
|
||||||
|
r"controller", r"verantwortliche\s+stelle",
|
||||||
|
r"responsible\s+(?:party|for)",
|
||||||
|
r"data\s+controller",
|
||||||
|
r"identity\s+(?:of\s+)?(?:the\s+)?controller",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 15(1)(a) VO 2018/1725 verlangt die Identitaet des Verantwortlichen. "
|
||||||
|
"Bei EU-Organen: Vollstaendiger Name der Institution (z.B. 'Europaeische Kommission, "
|
||||||
|
"GD DIGIT'), Dienstadresse und funktionale E-Mail-Adresse. "
|
||||||
|
"Haeufiger Fehler: Nur Abkuerzung ohne vollstaendigen Institutionsnamen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_controller_address",
|
||||||
|
"label": "Dienstadresse des Verantwortlichen",
|
||||||
|
"level": 2, "parent": "eu_controller",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:rue|avenue|boulevard|strasse|stra(?:ss|ß)e)\s+\w+",
|
||||||
|
r"\d{4,5}\s+(?:bruxelles|brussels|br(?:ue|ü)ssel|luxembourg|luxemburg|strasbourg|stra(?:ss|ß)burg)",
|
||||||
|
r"b[\-\s]?\d{4}\s+\w+",
|
||||||
|
r"l[\-\s]?\d{4}\s+\w+",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Angabe der Dienstadresse der EU-Institution (typisch: Bruessel, Luxemburg "
|
||||||
|
"oder Strassburg). Format z.B. 'Rue de la Loi 200, B-1049 Bruxelles'. "
|
||||||
|
"Haeufiger Fehler: Nur Postfach ohne physische Adresse."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_controller_email",
|
||||||
|
"label": "E-Mail-Adresse des Verantwortlichen",
|
||||||
|
"level": 2, "parent": "eu_controller",
|
||||||
|
"patterns": [
|
||||||
|
r"[a-z0-9._%+\-]+@[a-z0-9.\-]+\.europa\.eu",
|
||||||
|
r"[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Eine funktionale E-Mail-Adresse ist Pflicht (Art. 15(1)(a) VO 2018/1725). "
|
||||||
|
"Bei EU-Organen typischerweise @ec.europa.eu, @europarl.europa.eu o.ae. "
|
||||||
|
"Ein reines Kontaktformular genuegt nicht als unmittelbarer Kommunikationskanal."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
# == L1: Datenschutzbeauftragter (DPO) =================================
|
||||||
|
{
|
||||||
|
"id": "eu_dpo",
|
||||||
|
"label": "Datenschutzbeauftragter (Art. 15(1)(b) / Art. 43 VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"datenschutzbeauftragt",
|
||||||
|
r"data\s+protection\s+officer",
|
||||||
|
r"kontaktdaten\s+de[rs]\s+datenschutz",
|
||||||
|
r"dpo",
|
||||||
|
r"d(?:ae|ä)legu(?:e|é)\s+(?:a|à)\s+la\s+protection\s+des\s+donn(?:e|é)es",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 43-44 VO 2018/1725: Jedes EU-Organ MUSS einen DSB (DPO) benennen. "
|
||||||
|
"Dies ist — anders als unter der DSGVO — keine Frage der Mitarbeiterzahl, "
|
||||||
|
"sondern absolute Pflicht fuer alle EU-Organe. Die Kontaktdaten muessen in "
|
||||||
|
"jeder Datenschutzerklaerung angegeben werden (Art. 15(1)(b))."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_dpo_contact",
|
||||||
|
"label": "DPO-Kontaktdaten (E-Mail oder Adresse)",
|
||||||
|
"level": 2, "parent": "eu_dpo",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:data\s+protection\s+officer|dpo|datenschutzbeauftragt)[\s\S]{0,300}[a-z0-9._%+\-]+@",
|
||||||
|
r"dpo[\s\S]{0,100}@",
|
||||||
|
r"data[\-\.]?protection@",
|
||||||
|
r"dpo@\w+\.europa\.eu",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Art. 44(7) VO 2018/1725: Die Kontaktdaten des DPO muessen veroeffentlicht werden. "
|
||||||
|
"Mindestens eine funktionale E-Mail-Adresse angeben (z.B. DATA-PROTECTION-OFFICER@ec.europa.eu). "
|
||||||
|
"Den Namen des DPO muessen Sie nicht nennen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "eu_dpo_function",
|
||||||
|
"label": "DPO-Funktion / -Rolle beschrieben",
|
||||||
|
"level": 2, "parent": "eu_dpo",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:aufgaben|role|function|zustaendig).*(?:dpo|datenschutzbeauftragt|data\s+protection\s+officer)",
|
||||||
|
r"(?:dpo|datenschutzbeauftragt|data\s+protection\s+officer).*(?:aufgaben|role|function|zustaendig)",
|
||||||
|
r"art(?:icle)?\s*44\s+(?:vo|regulation|verordnung)",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 44 VO 2018/1725 beschreibt die Aufgaben des DPO bei EU-Organen: "
|
||||||
|
"Beratung, Ueberwachung, Zusammenarbeit mit dem EDSB. "
|
||||||
|
"Es empfiehlt sich, kurz die Rolle des DPO zu erlaeutern, damit "
|
||||||
|
"Betroffene wissen, wofuer der DPO zustaendig ist."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
# == L1: Zwecke und Rechtsgrundlage ====================================
|
||||||
|
{
|
||||||
|
"id": "eu_purposes",
|
||||||
|
"label": "Zwecke der Verarbeitung (Art. 15(1)(c) VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"zweck\w*\s+(?:der|und|die)\s+(?:verarbeitung|datenerhebung|datenverarbeitung)",
|
||||||
|
r"purpose\w*\s+(?:of|for)\s+(?:the\s+)?(?:processing|data)",
|
||||||
|
r"zu\s+welch\w+\s+zweck",
|
||||||
|
r"(?:data|personal\s+data)\s+(?:is|are)\s+(?:collected|processed)\s+(?:for|to)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 15(1)(c) VO 2018/1725 verlangt konkrete Zweckangaben. "
|
||||||
|
"EU-Organe muessen jeden Verarbeitungszweck einzeln auffuehren: z.B. "
|
||||||
|
"'Verwaltung von Bewerbungen', 'Zugangsmanagement zum Gebaeude', "
|
||||||
|
"'Webanalyse der Internetseite'. Pauschalformulierungen sind unzulaessig."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_purposes_specific",
|
||||||
|
"label": "Konkrete Verarbeitungszwecke benannt",
|
||||||
|
"level": 2, "parent": "eu_purposes",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:recruitment|selection|verwaltung|management|administration|monitoring|evaluation)",
|
||||||
|
r"(?:human\s+resources|hr|personal|bewerbung|grant|procurement|vergabe)",
|
||||||
|
r"(?:access|zugang|building|gebaeude|website|webseite|intranet)",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Mindestens 2 konkrete Zwecke benennen, jeweils mit zugehoeriger "
|
||||||
|
"Rechtsgrundlage. Typische EU-Organ-Zwecke: Personalverwaltung, "
|
||||||
|
"Gebaeudezugang, IT-Sicherheitsmonitoring, Vergabeverfahren, "
|
||||||
|
"Evaluierung von Foerderprogrammen. Pauschalformulierungen genuegen "
|
||||||
|
"nicht dem Bestimmtheitsgrundsatz."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
# == L1: Rechtsgrundlage (Art. 5 statt Art. 6 DSGVO) ==================
|
||||||
|
{
|
||||||
|
"id": "eu_legal_basis",
|
||||||
|
"label": "Rechtsgrundlage (Art. 5 VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"rechtsgrundlage",
|
||||||
|
r"art\.\s*5\s*(?:abs|absatz)?\s*\.?\s*1",
|
||||||
|
r"legal\s+basis",
|
||||||
|
r"lawfulness\s+of\s+processing",
|
||||||
|
r"art(?:icle)?\s*5\s*(?:\(1\))?\s*(?:\([a-d]\))?",
|
||||||
|
r"auf\s+grundlage\s+(?:von|des|der)\s+art",
|
||||||
|
r"regulation\s*\(eu\)\s*2018\s*/?\s*1725",
|
||||||
|
r"verordnung\s*\(eu\)\s*2018\s*/?\s*1725",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 5(1) VO 2018/1725 enthaelt die Rechtsgrundlagen fuer EU-Organe: "
|
||||||
|
"(a) Einwilligung, (b) Vertrag, (c) rechtliche Verpflichtung, "
|
||||||
|
"(d) im oeffentlichen Interesse/Ausuebung oeffentlicher Gewalt. "
|
||||||
|
"WICHTIG: Art. 5(1)(d) ist der haeufigste Tatbestand bei EU-Organen — "
|
||||||
|
"er entspricht etwa Art. 6(1)(e) DSGVO. Art. 6(1)(f) DSGVO "
|
||||||
|
"(berechtigtes Interesse) existiert in der VO 2018/1725 NICHT."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_legal_basis_public_interest",
|
||||||
|
"label": "Art. 5(1)(a) — Oeffentliches Interesse / oeffentliche Gewalt",
|
||||||
|
"level": 2, "parent": "eu_legal_basis",
|
||||||
|
"patterns": [
|
||||||
|
r"art\.\s*5\s*(?:\(1\))?\s*\(?(?:1\s*)?(?:let(?:ter)?\.?\s*)?a\)?",
|
||||||
|
r"(?:oeffentlich|öffentlich).*(?:interesse|gewalt|aufgabe)",
|
||||||
|
r"public\s+interest",
|
||||||
|
r"(?:exercise|performance)\s+of\s+(?:official|public)\s+(?:authority|task)",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 5(1)(a) VO 2018/1725 ist die Hauptrechtsgrundlage fuer EU-Organe. "
|
||||||
|
"Verlangt einen konkreten Rechtsakt als Grundlage (z.B. Verordnung, "
|
||||||
|
"Beschluss, Basisrechtsakt der Institution). Benennen Sie den spezifischen "
|
||||||
|
"Rechtsakt, nicht nur pauschal 'oeffentliches Interesse'."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_legal_basis_consent",
|
||||||
|
"label": "Art. 5(1)(d) — Einwilligung",
|
||||||
|
"level": 2, "parent": "eu_legal_basis",
|
||||||
|
"patterns": [
|
||||||
|
r"art\.\s*5\s*(?:\(1\))?\s*\(?(?:1\s*)?(?:let(?:ter)?\.?\s*)?d\)?",
|
||||||
|
r"einwilligung\s+(?:gem|nach|i\.?\s*s\.?\s*d\.?)",
|
||||||
|
r"consent\s+(?:of|given\s+by)\s+the\s+data\s+subject",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Bei Einwilligung (Art. 5(1)(d) VO 2018/1725) muss auf das jederzeitige "
|
||||||
|
"Widerrufsrecht hingewiesen werden (Art. 7(3) VO 2018/1725). "
|
||||||
|
"Achtung: EU-Organe sollten Einwilligung nur als Rechtsgrundlage waehlen, "
|
||||||
|
"wenn keine andere Grundlage greift — wegen des Machtungleichgewichts "
|
||||||
|
"zwischen Institution und Einzelperson (EDSB-Leitlinien)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
# == L1: Empfaenger ====================================================
|
||||||
|
{
|
||||||
|
"id": "eu_recipients",
|
||||||
|
"label": "Empfaenger (Art. 15(1)(e) VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"empf(?:ae|ä)nger",
|
||||||
|
r"(?:ueber|über|weiter)mitt(?:el|l)ung",
|
||||||
|
r"recipient",
|
||||||
|
r"weitergabe\s+(?:an|von)\s+daten",
|
||||||
|
r"data\s+(?:will\s+be|are|is)\s+(?:shared|disclosed|transferred|transmitted)\s+to",
|
||||||
|
r"auftragsverarbeit",
|
||||||
|
r"processor",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Art. 15(1)(e) VO 2018/1725: Empfaenger oder Empfaengerkategorien benennen. "
|
||||||
|
"Typisch bei EU-Organen: andere EU-Institutionen (z.B. OLAF, Rechnungshof), "
|
||||||
|
"Mitgliedstaaten-Behoerden, IT-Dienstleister. Auftragsverarbeiter muessen "
|
||||||
|
"nach Art. 29 VO 2018/1725 vertraglich gebunden sein."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_recipients_processor",
|
||||||
|
"label": "Auftragsverarbeiter / Processor (Art. 29 VO 2018/1725)",
|
||||||
|
"level": 2, "parent": "eu_recipients",
|
||||||
|
"patterns": [
|
||||||
|
r"auftragsverarbeit(?:er|ung)",
|
||||||
|
r"art\.\s*29\s+(?:vo|verordnung|regulation)",
|
||||||
|
r"art(?:icle)?\s*29",
|
||||||
|
r"processor",
|
||||||
|
r"sub[\-\s]?processor",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 29 VO 2018/1725 (entspricht Art. 28 DSGVO): "
|
||||||
|
"Auftragsverarbeiter muessen vertraglich gebunden werden. "
|
||||||
|
"Erwaehnen Sie, dass ein Auftragsverarbeitungsvertrag besteht. "
|
||||||
|
"Bei Cloud-Diensten (z.B. Microsoft 365, AWS): Vertrag muss "
|
||||||
|
"die Vorgaben von Art. 29(3) VO 2018/1725 einhalten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
# == L1: Drittlandtransfer =============================================
|
||||||
|
{
|
||||||
|
"id": "eu_third_country",
|
||||||
|
"label": "Drittlandtransfer (Art. 46-50 VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"drittland",
|
||||||
|
r"dritt\s*staat",
|
||||||
|
r"third\s+countr",
|
||||||
|
r"angemessenheitsbeschluss",
|
||||||
|
r"adequacy\s+decision",
|
||||||
|
r"standard\s*(?:vertragsklausel|contractual\s+clause)",
|
||||||
|
r"(?:transfer|uebermittlung|übermittlung).*(?:ausserhalb|außerhalb|outside)",
|
||||||
|
r"(?:europ(?:ae|ä)ischen\s+wirtschaftsraum|ewr|eea)",
|
||||||
|
r"art(?:icle)?\s*4[6-9]",
|
||||||
|
r"art\.\s*50",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Art. 46-50 VO 2018/1725 (entspricht Art. 44-49 DSGVO): "
|
||||||
|
"Drittlandtransfers erfordern Angemessenheitsbeschluss (Art. 47), "
|
||||||
|
"geeignete Garantien (Art. 48) oder Ausnahmen (Art. 50). "
|
||||||
|
"EDSB-Empfehlung: EU-Organe muessen besonders streng pruefen, "
|
||||||
|
"da sie eine Vorbildfunktion fuer die Mitgliedstaaten haben."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_third_country_mechanism",
|
||||||
|
"label": "Transfermechanismus benannt (Art. 47-48 VO 2018/1725)",
|
||||||
|
"level": 2, "parent": "eu_third_country",
|
||||||
|
"patterns": [
|
||||||
|
r"standard\s*vertragsklausel|scc|standard\s+contractual",
|
||||||
|
r"angemessenheitsbeschluss|adequacy\s+decision",
|
||||||
|
r"art(?:icle)?\s*4[7-8]",
|
||||||
|
r"data\s+privacy\s+framework|dpf",
|
||||||
|
r"appropriate\s+safeguards",
|
||||||
|
r"geeignete\s+garantien",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Art. 48 VO 2018/1725: Bei fehlender Angemessenheit koennen "
|
||||||
|
"geeignete Garantien (z.B. SCC, verbindliche Verwaltungsvereinbarungen) "
|
||||||
|
"den Transfer absichern. Der EDSB hat 2020 eigene Leitlinien zu "
|
||||||
|
"Drittlandtransfers fuer EU-Organe veroeffentlicht."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
# == L1: Speicherdauer =================================================
|
||||||
|
{
|
||||||
|
"id": "eu_retention",
|
||||||
|
"label": "Speicherdauer (Art. 15(1)(g) VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"speicherdauer",
|
||||||
|
r"aufbewahrungsfrist",
|
||||||
|
r"retention\s+period",
|
||||||
|
r"(?:how\s+long|storage\s+period|data\s+retention)",
|
||||||
|
r"l(?:oe|ö)sch(?:ung|frist)",
|
||||||
|
r"daten\s+werden\s+gel(?:oe|ö)scht",
|
||||||
|
r"(?:\d+\s+(?:tage|monate|jahre|days|months|years))",
|
||||||
|
r"dauer\s+der\s+speicherung",
|
||||||
|
r"data\s+will\s+be\s+(?:kept|stored|retained)\s+(?:for|until|during)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 15(1)(g) VO 2018/1725 verlangt die Speicherdauer oder "
|
||||||
|
"Kriterien zu deren Festlegung. EU-Organe haben oft interne "
|
||||||
|
"Aufbewahrungsrichtlinien (retention schedules). Nennen Sie die "
|
||||||
|
"konkreten Fristen oder verweisen Sie auf die interne Richtlinie "
|
||||||
|
"mit Dokumentenreferenz."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_retention_periods",
|
||||||
|
"label": "Konkrete Zeitangaben",
|
||||||
|
"level": 2, "parent": "eu_retention",
|
||||||
|
"patterns": [
|
||||||
|
r"\d+\s+(?:tage?|monate?|jahre?|days?|months?|years?)",
|
||||||
|
r"(?:after|nach)\s+(?:the\s+)?(?:end|closure|completion|ablauf|beendigung)",
|
||||||
|
r"retention\s+(?:schedule|policy|period)\s+(?:of|for)\s+\d+",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Konkrete Fristen pro Datenkategorie nennen. EU-Organe folgen "
|
||||||
|
"typischerweise der Common Retention List (CRL) der Kommission. "
|
||||||
|
"Beispiel: Bewerbungsdaten 2 Jahre, Finanzunterlagen 7 Jahre, "
|
||||||
|
"Gebaeudezugangslogs 6 Monate."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
# == L1: Betroffenenrechte (Art. 17-24 statt Art. 15-22 DSGVO) =========
|
||||||
|
{
|
||||||
|
"id": "eu_rights",
|
||||||
|
"label": "Betroffenenrechte (Art. 17-24 VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"recht\s+auf\s+auskunft",
|
||||||
|
r"recht\s+auf\s+l(?:oe|ö)schung",
|
||||||
|
r"recht\s+auf\s+berichtigung",
|
||||||
|
r"widerspruchsrecht",
|
||||||
|
r"right\s+to\s+(?:access|erasure|rectification|object|restrict)",
|
||||||
|
r"betroffenenrecht",
|
||||||
|
r"rechte\s+(?:des|der)\s+betroffenen",
|
||||||
|
r"(?:your|data\s+subject)\s+rights",
|
||||||
|
r"art(?:icle)?\s*(?:17|18|19|20|21|22|23|24)\s+(?:vo|regulation|verordnung)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 15(1)(h) VO 2018/1725 verlangt Nennung der Betroffenenrechte: "
|
||||||
|
"Auskunft (Art. 17), Berichtigung (Art. 18), Loeschung (Art. 19), "
|
||||||
|
"Einschraenkung (Art. 20), Datenportabilitaet (Art. 22), "
|
||||||
|
"Widerspruch (Art. 23). Achtung: Die Artikelnummern unterscheiden sich "
|
||||||
|
"von der DSGVO (Art. 15-22)! Haeufiger Fehler: DSGVO-Artikel "
|
||||||
|
"statt VO 2018/1725 Artikel zitieren."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_rights_access",
|
||||||
|
"label": "Recht auf Auskunft (Art. 17 VO 2018/1725)",
|
||||||
|
"level": 2, "parent": "eu_rights",
|
||||||
|
"patterns": [
|
||||||
|
r"art(?:icle)?\s*17\s+(?:vo|regulation|verordnung)",
|
||||||
|
r"art\.\s*17",
|
||||||
|
r"recht\s+auf\s+(?:\w+\s+)?auskunft",
|
||||||
|
r"right\s+(?:of|to)\s+access",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 17 VO 2018/1725 (entspricht Art. 15 DSGVO): Betroffene koennen "
|
||||||
|
"Auskunft und eine Kopie ihrer Daten verlangen. Antwortfrist: 1 Monat "
|
||||||
|
"(Art. 14(3) VO 2018/1725). Anfragen gehen typischerweise an den DPO "
|
||||||
|
"der Institution."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_rights_erasure",
|
||||||
|
"label": "Recht auf Loeschung (Art. 19 VO 2018/1725)",
|
||||||
|
"level": 2, "parent": "eu_rights",
|
||||||
|
"patterns": [
|
||||||
|
r"art(?:icle)?\s*19\s+(?:vo|regulation|verordnung)",
|
||||||
|
r"art\.\s*19",
|
||||||
|
r"recht\s+auf\s+(?:\w+\s+)?l(?:oe|ö)schung",
|
||||||
|
r"right\s+to\s+erasure",
|
||||||
|
r"right\s+to\s+be\s+forgotten",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 19 VO 2018/1725 (entspricht Art. 17 DSGVO): "
|
||||||
|
"Recht auf Loeschung bei Zweckentfall, Widerruf der Einwilligung "
|
||||||
|
"oder unrechtmaessiger Verarbeitung. Erwaehnen Sie auch die "
|
||||||
|
"Ausnahmen fuer EU-Organe: Archivzwecke im oeffentlichen Interesse, "
|
||||||
|
"gesetzliche Aufbewahrungspflichten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "eu_rights_restriction",
|
||||||
|
"label": "Recht auf Einschraenkung (Art. 20 VO 2018/1725)",
|
||||||
|
"level": 2, "parent": "eu_rights",
|
||||||
|
"patterns": [
|
||||||
|
r"art(?:icle)?\s*20\s+(?:vo|regulation|verordnung)",
|
||||||
|
r"art\.\s*20",
|
||||||
|
r"einschr(?:ae|ä)nkung\s+der\s+verarbeitung",
|
||||||
|
r"right\s+to\s+restrict(?:ion)?",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 20 VO 2018/1725 (entspricht Art. 18 DSGVO): "
|
||||||
|
"Recht auf Einschraenkung der Verarbeitung bei bestrittener "
|
||||||
|
"Richtigkeit, unrechtmaessiger Verarbeitung oder laufendem "
|
||||||
|
"Widerspruch. Wird am haeufigsten vergessen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_rights_automated",
|
||||||
|
"label": "Automatisierte Entscheidungen (Art. 24 VO 2018/1725)",
|
||||||
|
"level": 2, "parent": "eu_rights",
|
||||||
|
"patterns": [
|
||||||
|
r"art(?:icle)?\s*24\s+(?:vo|regulation|verordnung)",
|
||||||
|
r"art\.\s*24",
|
||||||
|
r"automatisierte\s+entscheidung",
|
||||||
|
r"automated\s+(?:decision|individual)",
|
||||||
|
r"profiling",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": (
|
||||||
|
"Art. 24 VO 2018/1725 (entspricht Art. 22 DSGVO): "
|
||||||
|
"Bei automatisierten Einzelentscheidungen muessen Logik, "
|
||||||
|
"Tragweite und Auswirkungen erklaert werden. Falls kein "
|
||||||
|
"Profiling stattfindet, explizit verneinen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
# == L1: Beschwerderecht beim EDSB =====================================
|
||||||
|
{
|
||||||
|
"id": "eu_complaint",
|
||||||
|
"label": "Beschwerderecht beim EDSB (Art. 15(1)(i) VO 2018/1725)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"beschwerderecht",
|
||||||
|
r"right\s+to\s+lodge\s+a\s+complaint",
|
||||||
|
r"beschwerde.*(?:edsb|edps)",
|
||||||
|
r"edsb",
|
||||||
|
r"edps",
|
||||||
|
r"europ(?:ae|ä)isch\w*\s+datenschutzbeauftragt",
|
||||||
|
r"european\s+data\s+protection\s+supervisor",
|
||||||
|
r"contr(?:o|ô)leur\s+europ(?:e|é)en",
|
||||||
|
r"art(?:icle)?\s*63",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": (
|
||||||
|
"Art. 15(1)(i) VO 2018/1725: Bei EU-Organen ist der EDSB "
|
||||||
|
"(Europaeischer Datenschutzbeauftragter / European Data Protection "
|
||||||
|
"Supervisor) die zustaendige Aufsichtsbehoerde — NICHT die nationalen "
|
||||||
|
"Datenschutzbehoerden. Kontakt: edps@edps.europa.eu, "
|
||||||
|
"Rue Wiertz 60, B-1047 Bruxelles. Haeufiger Fehler: Verweis auf "
|
||||||
|
"nationale Aufsichtsbehoerde statt EDSB."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eu_complaint_edps_contact",
|
||||||
|
"label": "EDSB-Kontaktdaten angegeben",
|
||||||
|
"level": 2, "parent": "eu_complaint",
|
||||||
|
"patterns": [
|
||||||
|
r"edps@edps\.europa\.eu",
|
||||||
|
r"edps\.europa\.eu",
|
||||||
|
r"edsb.*(?:kontakt|anschrift|adresse|e[\-\s]?mail|wiertz)",
|
||||||
|
r"edps.*(?:contact|address|e[\-\s]?mail|wiertz)",
|
||||||
|
r"rue\s+wiertz",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": (
|
||||||
|
"Vollstaendige EDSB-Kontaktdaten angeben: "
|
||||||
|
"Europaeischer Datenschutzbeauftragter (EDSB), "
|
||||||
|
"Rue Wiertz 60, B-1047 Bruxelles/Bruessel, "
|
||||||
|
"edps@edps.europa.eu, https://edps.europa.eu. "
|
||||||
|
"Haeufiger Fehler: Nur 'EDSB' erwaehnt ohne Kontaktdaten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -15,6 +15,7 @@ from .impressum_checks import IMPRESSUM_CHECKLIST
|
|||||||
from .cookie_checks import COOKIE_CHECKLIST
|
from .cookie_checks import COOKIE_CHECKLIST
|
||||||
from .social_media_checks import JOINT_CONTROLLER_CHECKLIST
|
from .social_media_checks import JOINT_CONTROLLER_CHECKLIST
|
||||||
from .dsfa_checks import DSFA_CHECKLIST
|
from .dsfa_checks import DSFA_CHECKLIST
|
||||||
|
from .eu_institution_checks import EU_INSTITUTION_CHECKLIST
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ _CHECKLIST_MAP = {
|
|||||||
"social_media": (JOINT_CONTROLLER_CHECKLIST, "Art. 26 DSGVO"),
|
"social_media": (JOINT_CONTROLLER_CHECKLIST, "Art. 26 DSGVO"),
|
||||||
"joint_controller": (JOINT_CONTROLLER_CHECKLIST, "Art. 26 DSGVO"),
|
"joint_controller": (JOINT_CONTROLLER_CHECKLIST, "Art. 26 DSGVO"),
|
||||||
"dsfa": (DSFA_CHECKLIST, "Art. 35 DSGVO"),
|
"dsfa": (DSFA_CHECKLIST, "Art. 35 DSGVO"),
|
||||||
|
"eu_institution": (EU_INSTITUTION_CHECKLIST, "VO (EU) 2018/1725"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +220,11 @@ def classify_document_type(title: str, url: str) -> str:
|
|||||||
if any(kw in combined for kw in ["social media", "facebook", "instagram", "linkedin", "fanpage"]):
|
if any(kw in combined for kw in ["social media", "facebook", "instagram", "linkedin", "fanpage"]):
|
||||||
if any(kw in combined for kw in ["datenschutzerkl", "datenschutz für", "datenschutzinformation"]):
|
if any(kw in combined for kw in ["datenschutzerkl", "datenschutz für", "datenschutzinformation"]):
|
||||||
return "social_media"
|
return "social_media"
|
||||||
|
# EU institution check BEFORE generic privacy — 2018/1725 is more specific
|
||||||
|
if any(kw in combined for kw in ["2018/1725", "2018 1725", "regulation (eu)",
|
||||||
|
"verordnung (eu)", "edsb", "edps",
|
||||||
|
"european data protection supervisor"]):
|
||||||
|
return "eu_institution"
|
||||||
if any(kw in combined for kw in ["datenschutz", "privacy", "dsgvo", "data protection", "données"]):
|
if any(kw in combined for kw in ["datenschutz", "privacy", "dsgvo", "data protection", "données"]):
|
||||||
return "dse"
|
return "dse"
|
||||||
if any(kw in combined for kw in ["widerruf", "withdrawal", "rétractation", "desistimiento"]):
|
if any(kw in combined for kw in ["widerruf", "withdrawal", "rétractation", "desistimiento"]):
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
Banner Runner — maps scan results to the L1/L2 check hierarchy.
|
||||||
|
|
||||||
|
Takes the raw ScanResponse dict and produces a structured_checks list
|
||||||
|
compatible with ChecklistView (same format as document checks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from checks.banner_checks import BANNER_CHECKLIST
|
||||||
|
|
||||||
|
|
||||||
|
def map_scan_to_checks(scan_result: dict) -> dict:
|
||||||
|
"""Map a /scan response to the L1/L2 banner check hierarchy.
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
- structured_checks: list of CheckItem dicts
|
||||||
|
- completeness_pct: L1 pass rate (0-100)
|
||||||
|
- correctness_pct: L2 pass rate (0-100)
|
||||||
|
"""
|
||||||
|
# Collect all violation codes from every source
|
||||||
|
violation_codes = _collect_violation_codes(scan_result)
|
||||||
|
|
||||||
|
# Collect pass codes — some checks produce boolean signals, not violations
|
||||||
|
pass_codes = _collect_pass_codes(scan_result)
|
||||||
|
|
||||||
|
# Build structured checks
|
||||||
|
checks: list[dict] = []
|
||||||
|
l1_checks: list[dict] = []
|
||||||
|
l2_checks: list[dict] = []
|
||||||
|
|
||||||
|
for defn in BANNER_CHECKLIST:
|
||||||
|
key = defn["check_key"]
|
||||||
|
level = defn["level"]
|
||||||
|
parent = defn.get("parent")
|
||||||
|
|
||||||
|
# Determine pass/fail
|
||||||
|
is_violation_key = key in violation_codes
|
||||||
|
is_pass_key = key in pass_codes
|
||||||
|
|
||||||
|
# For checks whose check_key appears in violations → failed
|
||||||
|
# For checks whose check_key appears only in passes → passed
|
||||||
|
# For checks where neither → assume passed (not tested = no finding)
|
||||||
|
if is_violation_key:
|
||||||
|
passed = False
|
||||||
|
matched_text = violation_codes[key]
|
||||||
|
elif is_pass_key:
|
||||||
|
passed = True
|
||||||
|
matched_text = pass_codes.get(key, "")
|
||||||
|
else:
|
||||||
|
# Key not found in violations or explicit passes.
|
||||||
|
# If the scan ran (banner detected) → assume passed.
|
||||||
|
# If banner not detected → only banner_detected fails.
|
||||||
|
passed = scan_result.get("banner_detected", False) or key == "banner_detected"
|
||||||
|
if key == "banner_detected":
|
||||||
|
passed = scan_result.get("banner_detected", False)
|
||||||
|
matched_text = ""
|
||||||
|
|
||||||
|
# L2 checks are skipped if their parent L1 failed
|
||||||
|
skipped = False
|
||||||
|
if level == 2 and parent:
|
||||||
|
parent_check = next(
|
||||||
|
(c for c in checks if c["id"] == parent), None
|
||||||
|
)
|
||||||
|
if parent_check and not parent_check["passed"]:
|
||||||
|
skipped = True
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"id": defn["id"],
|
||||||
|
"label": defn["label"],
|
||||||
|
"passed": passed and not skipped,
|
||||||
|
"severity": defn["severity"],
|
||||||
|
"level": level,
|
||||||
|
"parent": parent,
|
||||||
|
"skipped": skipped,
|
||||||
|
"hint": defn.get("hint", ""),
|
||||||
|
"matched_text": matched_text if passed else "",
|
||||||
|
}
|
||||||
|
checks.append(item)
|
||||||
|
|
||||||
|
if level == 1:
|
||||||
|
l1_checks.append(item)
|
||||||
|
elif level == 2:
|
||||||
|
l2_checks.append(item)
|
||||||
|
|
||||||
|
# Compute percentages
|
||||||
|
l1_total = len(l1_checks)
|
||||||
|
l1_passed = sum(1 for c in l1_checks if c["passed"])
|
||||||
|
completeness_pct = round(l1_passed / l1_total * 100) if l1_total else 0
|
||||||
|
|
||||||
|
l2_active = [c for c in l2_checks if not c["skipped"]]
|
||||||
|
l2_passed = sum(1 for c in l2_active if c["passed"])
|
||||||
|
correctness_pct = round(l2_passed / len(l2_active) * 100) if l2_active else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"structured_checks": checks,
|
||||||
|
"completeness_pct": completeness_pct,
|
||||||
|
"correctness_pct": correctness_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_violation_codes(scan: dict) -> dict[str, str]:
|
||||||
|
"""Collect check_key → violation text from all sources."""
|
||||||
|
codes: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Banner text violations
|
||||||
|
banner_checks = scan.get("banner_checks", {})
|
||||||
|
for v in banner_checks.get("violations", []):
|
||||||
|
code = v.get("code", "")
|
||||||
|
if code:
|
||||||
|
codes[code] = v.get("text", "")[:120]
|
||||||
|
|
||||||
|
# Phase A violations (before consent)
|
||||||
|
phase_a = scan.get("phases", {}).get("before_consent", {})
|
||||||
|
for v in phase_a.get("violations", []):
|
||||||
|
code = v.get("code", "")
|
||||||
|
if code:
|
||||||
|
codes[code] = v.get("text", "")[:120]
|
||||||
|
|
||||||
|
# Phase B violations (after reject)
|
||||||
|
phase_b = scan.get("phases", {}).get("after_reject", {})
|
||||||
|
for v in phase_b.get("violations", []):
|
||||||
|
code = v.get("code", "")
|
||||||
|
if code:
|
||||||
|
codes[code] = v.get("text", "")[:120]
|
||||||
|
|
||||||
|
# Tracking services in phase A → tracking_before_consent
|
||||||
|
tracking_a = phase_a.get("tracking_services", [])
|
||||||
|
if tracking_a and "tracking_before_consent" not in codes:
|
||||||
|
codes["tracking_before_consent"] = ", ".join(tracking_a[:5])
|
||||||
|
|
||||||
|
# Cookies before consent → cookies_before_consent
|
||||||
|
cookies_a = phase_a.get("cookies", [])
|
||||||
|
tracking_cookies = [c for c in cookies_a if _is_tracking_cookie(c)]
|
||||||
|
if tracking_cookies and "cookies_before_consent" not in codes:
|
||||||
|
codes["cookies_before_consent"] = ", ".join(tracking_cookies[:5])
|
||||||
|
|
||||||
|
# New tracking after reject → tracking_after_reject
|
||||||
|
new_tracking_b = phase_b.get("new_tracking", [])
|
||||||
|
if new_tracking_b and "tracking_after_reject" not in codes:
|
||||||
|
codes["tracking_after_reject"] = ", ".join(new_tracking_b[:5])
|
||||||
|
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_pass_codes(scan: dict) -> dict[str, str]:
|
||||||
|
"""Collect explicit pass signals from scan results."""
|
||||||
|
passes: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Banner detected
|
||||||
|
if scan.get("banner_detected"):
|
||||||
|
passes["banner_detected"] = scan.get("banner_provider", "detected")
|
||||||
|
|
||||||
|
# Provider named
|
||||||
|
provider = scan.get("banner_provider", "")
|
||||||
|
if provider:
|
||||||
|
passes["banner_provider_named"] = provider
|
||||||
|
|
||||||
|
# Impressum link
|
||||||
|
bc = scan.get("banner_checks", {})
|
||||||
|
if bc.get("has_impressum_link"):
|
||||||
|
passes["impressum_link"] = "Impressum-Link gefunden"
|
||||||
|
if bc.get("has_dse_link"):
|
||||||
|
passes["dse_link"] = "DSE-Link gefunden"
|
||||||
|
|
||||||
|
return passes
|
||||||
|
|
||||||
|
|
||||||
|
_TRACKING_COOKIE_PREFIXES = (
|
||||||
|
"_ga", "_gid", "_fbp", "_fbc", "IDE", "_gcl", "fr", "_pin",
|
||||||
|
"_tt_", "li_sugr", "_hj", "mp_", "ajs_", "_clck", "_clsk",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tracking_cookie(name: str) -> bool:
|
||||||
|
"""Check if a cookie name is a known tracking cookie."""
|
||||||
|
return any(name.startswith(p) for p in _TRACKING_COOKIE_PREFIXES)
|
||||||
+45
-25
@@ -16,6 +16,7 @@ from services.consent_scanner import run_consent_test, ConsentTestResult
|
|||||||
from services.authenticated_scanner import run_authenticated_test, AuthTestResult
|
from services.authenticated_scanner import run_authenticated_test, AuthTestResult
|
||||||
from services.playwright_scanner import scan_website_playwright
|
from services.playwright_scanner import scan_website_playwright
|
||||||
from services.dsi_discovery import discover_dsi_documents, DSIDiscoveryResult
|
from services.dsi_discovery import discover_dsi_documents, DSIDiscoveryResult
|
||||||
|
from checks.banner_runner import map_scan_to_checks
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -44,6 +45,9 @@ class ScanResponse(BaseModel):
|
|||||||
scanned_at: str
|
scanned_at: str
|
||||||
category_tests: list = []
|
category_tests: list = []
|
||||||
banner_checks: dict = {}
|
banner_checks: dict = {}
|
||||||
|
structured_checks: list = []
|
||||||
|
completeness_pct: int = 0
|
||||||
|
correctness_pct: int = 0
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -57,30 +61,47 @@ async def scan_consent(req: ScanRequest):
|
|||||||
logger.info("Starting consent test for %s", req.url)
|
logger.info("Starting consent test for %s", req.url)
|
||||||
result = await run_consent_test(req.url, req.timeout_per_phase)
|
result = await run_consent_test(req.url, req.timeout_per_phase)
|
||||||
|
|
||||||
|
# Build raw response dict for structured check mapping
|
||||||
|
phases = {
|
||||||
|
"before_consent": {
|
||||||
|
"scripts": result.before_scripts,
|
||||||
|
"cookies": result.before_cookies,
|
||||||
|
"tracking_services": result.before_tracking,
|
||||||
|
"violations": [v.__dict__ for v in result.before_violations],
|
||||||
|
},
|
||||||
|
"after_reject": {
|
||||||
|
"scripts": result.reject_scripts,
|
||||||
|
"cookies": result.reject_cookies,
|
||||||
|
"new_tracking": result.reject_new_tracking,
|
||||||
|
"violations": [v.__dict__ for v in result.reject_violations],
|
||||||
|
},
|
||||||
|
"after_accept": {
|
||||||
|
"scripts": result.accept_scripts,
|
||||||
|
"cookies": result.accept_cookies,
|
||||||
|
"new_tracking": result.accept_new_tracking,
|
||||||
|
"undocumented": result.accept_undocumented,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
banner_checks_data = {
|
||||||
|
"has_impressum_link": result.banner_has_impressum_link,
|
||||||
|
"has_dse_link": result.banner_has_dse_link,
|
||||||
|
"violations": [v.__dict__ for v in result.banner_text_violations],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map to L1/L2 hierarchy
|
||||||
|
raw_for_mapping = {
|
||||||
|
"banner_detected": result.banner_detected,
|
||||||
|
"banner_provider": result.banner_provider,
|
||||||
|
"phases": phases,
|
||||||
|
"banner_checks": banner_checks_data,
|
||||||
|
}
|
||||||
|
check_result = map_scan_to_checks(raw_for_mapping)
|
||||||
|
|
||||||
return ScanResponse(
|
return ScanResponse(
|
||||||
url=req.url,
|
url=req.url,
|
||||||
banner_detected=result.banner_detected,
|
banner_detected=result.banner_detected,
|
||||||
banner_provider=result.banner_provider,
|
banner_provider=result.banner_provider,
|
||||||
phases={
|
phases=phases,
|
||||||
"before_consent": {
|
|
||||||
"scripts": result.before_scripts,
|
|
||||||
"cookies": result.before_cookies,
|
|
||||||
"tracking_services": result.before_tracking,
|
|
||||||
"violations": [v.__dict__ for v in result.before_violations],
|
|
||||||
},
|
|
||||||
"after_reject": {
|
|
||||||
"scripts": result.reject_scripts,
|
|
||||||
"cookies": result.reject_cookies,
|
|
||||||
"new_tracking": result.reject_new_tracking,
|
|
||||||
"violations": [v.__dict__ for v in result.reject_violations],
|
|
||||||
},
|
|
||||||
"after_accept": {
|
|
||||||
"scripts": result.accept_scripts,
|
|
||||||
"cookies": result.accept_cookies,
|
|
||||||
"new_tracking": result.accept_new_tracking,
|
|
||||||
"undocumented": result.accept_undocumented,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
summary={
|
summary={
|
||||||
"critical": sum(1 for v in result.reject_violations if v.severity == "CRITICAL"),
|
"critical": sum(1 for v in result.reject_violations if v.severity == "CRITICAL"),
|
||||||
"high": len(result.before_violations) + sum(1 for v in result.banner_text_violations if v.severity == "HIGH"),
|
"high": len(result.before_violations) + sum(1 for v in result.banner_text_violations if v.severity == "HIGH"),
|
||||||
@@ -90,11 +111,10 @@ async def scan_consent(req: ScanRequest):
|
|||||||
"categories_tested": len(result.category_tests),
|
"categories_tested": len(result.category_tests),
|
||||||
"banner_text_issues": len(result.banner_text_violations),
|
"banner_text_issues": len(result.banner_text_violations),
|
||||||
},
|
},
|
||||||
banner_checks={
|
banner_checks=banner_checks_data,
|
||||||
"has_impressum_link": result.banner_has_impressum_link,
|
structured_checks=check_result["structured_checks"],
|
||||||
"has_dse_link": result.banner_has_dse_link,
|
completeness_pct=check_result["completeness_pct"],
|
||||||
"violations": [v.__dict__ for v in result.banner_text_violations],
|
correctness_pct=check_result["correctness_pct"],
|
||||||
},
|
|
||||||
scanned_at=datetime.now(timezone.utc).isoformat(),
|
scanned_at=datetime.now(timezone.utc).isoformat(),
|
||||||
category_tests=[{
|
category_tests=[{
|
||||||
"category": ct.category,
|
"category": ct.category,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from urllib.parse import urlparse, urljoin
|
|||||||
|
|
||||||
from playwright.async_api import Page
|
from playwright.async_api import Page
|
||||||
|
|
||||||
|
from services.dsi_helpers import goto_resilient, try_dismiss_consent_banner, is_pdf_redirect
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Legal document keywords in all EU/EEA official languages.
|
# Legal document keywords in all EU/EEA official languages.
|
||||||
@@ -216,11 +218,36 @@ async def discover_dsi_documents(
|
|||||||
seen_titles: set[str] = set()
|
seen_titles: set[str] = set()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Load the page
|
# Step 1: Load the page (with networkidle → domcontentloaded fallback)
|
||||||
await page.goto(url, wait_until="networkidle", timeout=60000)
|
await goto_resilient(page, url, timeout=60000)
|
||||||
await page.wait_for_timeout(2000)
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
# Step 1b: Self-extraction — if the URL itself is a DSI page,
|
# Step 1a: Detect PDF redirects (e.g. dm.de redirects to GCS PDF)
|
||||||
|
final_url = page.url
|
||||||
|
if is_pdf_redirect(url, final_url):
|
||||||
|
is_dsi_url, dsi_lang = _matches_dsi_keyword(urlparse(url).path.lower())
|
||||||
|
if is_dsi_url:
|
||||||
|
result.documents.append(DiscoveredDSI(
|
||||||
|
title=urlparse(url).path.split("/")[-1] or "Datenschutzerklaerung",
|
||||||
|
url=final_url,
|
||||||
|
source_url=url,
|
||||||
|
language=dsi_lang or "de",
|
||||||
|
doc_type="pdf",
|
||||||
|
text="[PDF — Textextraktion erforderlich]",
|
||||||
|
))
|
||||||
|
seen_urls.add(url)
|
||||||
|
seen_urls.add(final_url)
|
||||||
|
logger.info("PDF redirect detected: %s -> %s", url, final_url)
|
||||||
|
# Return early — a PDF redirect means no HTML content to scan
|
||||||
|
result.total_found = len(result.documents)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 1b: Try dismissing cookie consent banners before extraction.
|
||||||
|
# Many German sites (dm.de, Zalando, etc.) block page content behind
|
||||||
|
# a consent wall. Dismissing it reveals the actual DSI text.
|
||||||
|
await try_dismiss_consent_banner(page)
|
||||||
|
|
||||||
|
# Step 1c: Self-extraction — if the URL itself is a DSI page,
|
||||||
# extract its full text as the first document. This handles the
|
# extract its full text as the first document. This handles the
|
||||||
# case where the user provides the DSE URL directly (e.g.
|
# case where the user provides the DSE URL directly (e.g.
|
||||||
# example.com/datenschutz) instead of the homepage.
|
# example.com/datenschutz) instead of the homepage.
|
||||||
@@ -251,6 +278,8 @@ async def discover_dsi_documents(
|
|||||||
))
|
))
|
||||||
seen_urls.add(url)
|
seen_urls.add(url)
|
||||||
logger.info("Self-extracted %d words from %s", self_wc, url)
|
logger.info("Self-extracted %d words from %s", self_wc, url)
|
||||||
|
else:
|
||||||
|
logger.info("Self-extraction too short (%d words) for %s", self_wc, url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Self-extraction failed for %s: %s", url, e)
|
logger.warning("Self-extraction failed for %s: %s", url, e)
|
||||||
|
|
||||||
@@ -323,58 +352,69 @@ async def discover_dsi_documents(
|
|||||||
if is_anchor:
|
if is_anchor:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Navigate to page — wait for JS to load content
|
# Navigate to page — with networkidle/domcontentloaded fallback
|
||||||
resp = await page.goto(href, wait_until="networkidle", timeout=45000)
|
await goto_resilient(page, href, timeout=45000)
|
||||||
if resp and resp.status < 400:
|
resp_url = page.url
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
await _expand_all_interactive(page)
|
|
||||||
await page.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# Extract text — try specific content areas, fall back to full body
|
# Check for PDF redirect on followed links
|
||||||
text = await page.evaluate("""
|
if is_pdf_redirect(href, resp_url):
|
||||||
() => {
|
result.documents.append(DiscoveredDSI(
|
||||||
// Try progressively broader content selectors
|
title=title, url=resp_url, source_url=url,
|
||||||
const selectors = [
|
language=lang, doc_type="pdf",
|
||||||
'.article-content', '.page-content', '.entry-content',
|
text="[PDF — Textextraktion erforderlich]",
|
||||||
'[class*="content-area"]', '[class*="main-content"]',
|
))
|
||||||
'main article', 'main', 'article',
|
await goto_resilient(page, url, timeout=45000)
|
||||||
'[role="main"]', '.content', '#content',
|
continue
|
||||||
];
|
|
||||||
for (const sel of selectors) {
|
await try_dismiss_consent_banner(page)
|
||||||
const el = document.querySelector(sel);
|
await _expand_all_interactive(page)
|
||||||
if (el && el.textContent.trim().length > 200) {
|
await page.wait_for_timeout(500)
|
||||||
return el.textContent.trim();
|
|
||||||
}
|
# Extract text — try specific content areas, fall back to full body
|
||||||
|
text = await page.evaluate("""
|
||||||
|
() => {
|
||||||
|
// Try progressively broader content selectors
|
||||||
|
const selectors = [
|
||||||
|
'.article-content', '.page-content', '.entry-content',
|
||||||
|
'[class*="content-area"]', '[class*="main-content"]',
|
||||||
|
'main article', 'main', 'article',
|
||||||
|
'[role="main"]', '.content', '#content',
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (el && el.textContent.trim().length > 200) {
|
||||||
|
return el.textContent.trim();
|
||||||
}
|
}
|
||||||
// Fallback: full body minus nav/header/footer
|
|
||||||
const body = document.body.cloneNode(true);
|
|
||||||
body.querySelectorAll('nav, header, footer, script, style, [class*="nav"], [class*="sidebar"]').forEach(e => e.remove());
|
|
||||||
return body.textContent?.trim() || '';
|
|
||||||
}
|
}
|
||||||
""")
|
// Fallback: full body minus nav/header/footer
|
||||||
if text and len(text) > 50:
|
const body = document.body.cloneNode(true);
|
||||||
result.documents.append(DiscoveredDSI(
|
body.querySelectorAll('nav, header, footer, script, style, [class*="nav"], [class*="sidebar"]').forEach(e => e.remove());
|
||||||
title=title, url=href, source_url=url,
|
return body.textContent?.trim() || '';
|
||||||
language=lang,
|
}
|
||||||
doc_type="cross_domain" if not _is_allowed_domain(href, base_domain) else "html_page",
|
""")
|
||||||
text=text[:50000], word_count=len(text.split()),
|
if text and len(text) > 50:
|
||||||
))
|
result.documents.append(DiscoveredDSI(
|
||||||
|
title=title, url=href, source_url=url,
|
||||||
|
language=lang,
|
||||||
|
doc_type="cross_domain" if not _is_allowed_domain(href, base_domain) else "html_page",
|
||||||
|
text=text[:50000], word_count=len(text.split()),
|
||||||
|
))
|
||||||
|
|
||||||
# Recursive: search THIS page for more DSI links
|
# Recursive: search THIS page for more DSI links
|
||||||
new_links = await _find_dsi_links(page, base_domain)
|
new_links = await _find_dsi_links(page, base_domain)
|
||||||
for nl in new_links:
|
for nl in new_links:
|
||||||
if nl["href"] not in seen_urls and nl["href"] not in [p["href"] for p in pending_links]:
|
if nl["href"] not in seen_urls and nl["href"] not in [p["href"] for p in pending_links]:
|
||||||
pending_links.append(nl)
|
pending_links.append(nl)
|
||||||
|
|
||||||
# Navigate back for next link
|
# Navigate back for next link
|
||||||
await page.goto(url, wait_until="networkidle", timeout=45000)
|
await goto_resilient(page, url, timeout=45000)
|
||||||
await page.wait_for_timeout(500)
|
await page.wait_for_timeout(500)
|
||||||
await _expand_all_interactive(page)
|
await _expand_all_interactive(page)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result.errors.append(f"Failed to load {href}: {str(e)[:80]}")
|
result.errors.append(f"Failed to load {href}: {str(e)[:80]}")
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until="networkidle", timeout=45000)
|
await goto_resilient(page, url, timeout=45000)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
DSI Discovery Helpers — resilient navigation, consent dismissal, PDF redirect detection.
|
||||||
|
|
||||||
|
Extracted from dsi_discovery.py to keep modules under 500 LOC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from playwright.async_api import Page, TimeoutError as PlaywrightTimeout
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def goto_resilient(page: Page, url: str, timeout: int = 60000) -> None:
|
||||||
|
"""Navigate to URL with fallback: try networkidle first, then domcontentloaded.
|
||||||
|
|
||||||
|
SPAs like Zalando never reach networkidle because of continuous background
|
||||||
|
requests. Falling back to domcontentloaded + a short wait gives JS time to
|
||||||
|
render the main content without waiting for every network request to finish.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await page.goto(url, wait_until="networkidle", timeout=timeout)
|
||||||
|
except PlaywrightTimeout:
|
||||||
|
logger.info("networkidle timeout for %s, falling back to domcontentloaded", url)
|
||||||
|
await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
||||||
|
await page.wait_for_timeout(5000) # extra wait for JS rendering
|
||||||
|
|
||||||
|
|
||||||
|
async def try_dismiss_consent_banner(page: Page) -> bool:
|
||||||
|
"""Try to dismiss cookie consent banners that block page content.
|
||||||
|
|
||||||
|
Handles shadow DOM (Usercentrics), iframes (Sourcepoint), and regular
|
||||||
|
DOM banners (OneTrust, Cookiebot, Didomi, etc.).
|
||||||
|
Returns True if a banner was dismissed.
|
||||||
|
"""
|
||||||
|
# 1) Usercentrics shadow DOM — most common for German sites
|
||||||
|
try:
|
||||||
|
uc_root = await page.query_selector("#usercentrics-root")
|
||||||
|
if uc_root:
|
||||||
|
clicked = await page.evaluate("""() => {
|
||||||
|
const root = document.querySelector('#usercentrics-root');
|
||||||
|
if (!root || !root.shadowRoot) return false;
|
||||||
|
const buttons = root.shadowRoot.querySelectorAll('button');
|
||||||
|
for (const btn of buttons) {
|
||||||
|
const t = btn.textContent.trim().toLowerCase();
|
||||||
|
if (t.includes('akzeptieren') || t.includes('accept')
|
||||||
|
|| t.includes('zustimmen') || t.includes('agree')) {
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}""")
|
||||||
|
if clicked:
|
||||||
|
logger.info("Dismissed Usercentrics consent banner (shadow DOM)")
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2) Standard DOM banners — OneTrust, Cookiebot, Didomi, Borlabs, etc.
|
||||||
|
accept_selectors = [
|
||||||
|
"#onetrust-accept-btn-handler",
|
||||||
|
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
|
||||||
|
"#didomi-notice-agree-button",
|
||||||
|
"#BorlabsCookieBox .cookie-accept, [data-cookie-accept]",
|
||||||
|
".cmpboxbtn.cmpboxbtnyes",
|
||||||
|
".klaro .cm-btn-accept",
|
||||||
|
".cky-btn-accept",
|
||||||
|
"[class*='qc-cmp2-summary-buttons'] button:first-child",
|
||||||
|
"#tarteaucitronPersonalize2",
|
||||||
|
]
|
||||||
|
for sel in accept_selectors:
|
||||||
|
try:
|
||||||
|
btn = page.locator(sel).first
|
||||||
|
if await btn.count() > 0 and await btn.is_visible():
|
||||||
|
await btn.click(timeout=3000)
|
||||||
|
logger.info("Dismissed consent banner via %s", sel)
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3) Generic text-based button search
|
||||||
|
accept_texts = [
|
||||||
|
"Alle akzeptieren", "Alles akzeptieren", "Alle Cookies akzeptieren",
|
||||||
|
"Accept all", "Accept All Cookies", "Akzeptieren", "Zustimmen",
|
||||||
|
"Einverstanden", "Ich stimme zu",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
clicked = await page.evaluate("""(texts) => {
|
||||||
|
for (const btn of document.querySelectorAll('button, a[role="button"]')) {
|
||||||
|
const t = (btn.textContent || '').trim();
|
||||||
|
for (const target of texts) {
|
||||||
|
if (t === target) { btn.click(); return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}""", accept_texts)
|
||||||
|
if clicked:
|
||||||
|
logger.info("Dismissed consent banner via generic text match")
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_pdf_redirect(original_url: str, final_url: str) -> bool:
|
||||||
|
"""Check if the page redirected to a PDF or external storage."""
|
||||||
|
final_lower = final_url.lower()
|
||||||
|
return (
|
||||||
|
final_lower.endswith(".pdf")
|
||||||
|
or "storage.googleapis.com" in final_lower
|
||||||
|
or "blob.core.windows.net" in final_lower
|
||||||
|
or "s3.amazonaws.com" in final_lower
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user