Compare commits
2 Commits
c867478791
...
0b9150f16f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b9150f16f | |||
| 0326d5baab |
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Vendor Assessment Status/Detail Proxy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) },
|
||||||
|
)
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Assessment status proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}/approve`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Assessment approve proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Vendor Assessment API Proxy
|
||||||
|
* Proxies to backend-compliance (Python FastAPI)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor assessment proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor assessment list proxy error:', error)
|
||||||
|
return NextResponse.json({ assessments: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assessmentId: string
|
||||||
|
backendUrl: string
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onComplete: (result: any) => void
|
||||||
|
onError: (msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssessmentProgress({ assessmentId, backendUrl, onComplete, onError }: Props) {
|
||||||
|
const [progress, setProgress] = useState('Initialisierung...')
|
||||||
|
const [dots, setDots] = useState(0)
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dotTimer = setInterval(() => setDots(d => (d + 1) % 4), 500)
|
||||||
|
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${backendUrl}/api/vendor-compliance/assessments/${assessmentId}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) return
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.status === 'completed' && data.result) {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
clearInterval(dotTimer)
|
||||||
|
onComplete(data.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
clearInterval(dotTimer)
|
||||||
|
onError(data.error || 'Pruefung fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.progress) {
|
||||||
|
setProgress(data.progress)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry silently
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
clearInterval(dotTimer)
|
||||||
|
}
|
||||||
|
}, [assessmentId, backendUrl, onComplete, onError])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-6 relative">
|
||||||
|
<div className="absolute inset-0 border-4 border-blue-200 rounded-full" />
|
||||||
|
<div className="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
Vertragspruefung laeuft
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-sm mb-6">
|
||||||
|
{progress}{'.'.repeat(dots)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">1</span>
|
||||||
|
Text extrahieren
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">2</span>
|
||||||
|
Checklisten pruefen (L1/L2)
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">3</span>
|
||||||
|
Cross-Check zwischen Dokumenten
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-left text-xs text-gray-500">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">4</span>
|
||||||
|
Pruefprotokoll generieren
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface DocumentEntry {
|
||||||
|
doc_type: string
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onStart: (vendorName: string, documents: DocumentEntry[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPES = [
|
||||||
|
{ value: 'auto', label: 'Automatisch erkennen' },
|
||||||
|
{ value: 'avv', label: 'AVV / Auftragsverarbeitungsvertrag' },
|
||||||
|
{ value: 'scc', label: 'SCC / Standardvertragsklauseln' },
|
||||||
|
{ value: 'tom_annex', label: 'TOM-Anlage (Art. 32)' },
|
||||||
|
{ value: 'sub_processor_list', label: 'Sub-Processor-Liste' },
|
||||||
|
{ value: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function DocumentUploader({ onStart }: Props) {
|
||||||
|
const [vendorName, setVendorName] = useState('')
|
||||||
|
const [entries, setEntries] = useState<DocumentEntry[]>([
|
||||||
|
{ doc_type: 'auto', label: '', url: '' },
|
||||||
|
])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const updateEntry = (idx: number, field: keyof DocumentEntry, value: string) => {
|
||||||
|
setEntries(prev => {
|
||||||
|
const copy = [...prev]
|
||||||
|
copy[idx] = { ...copy[idx], [field]: value }
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
setEntries(prev => [...prev, { doc_type: 'auto', label: '', url: '' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntry = (idx: number) => {
|
||||||
|
if (entries.length <= 1) return
|
||||||
|
setEntries(prev => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const valid = entries.filter(d => d.url.trim())
|
||||||
|
if (!vendorName.trim() || valid.length === 0) return
|
||||||
|
setLoading(true)
|
||||||
|
onStart(vendorName.trim(), valid.map(d => ({
|
||||||
|
...d,
|
||||||
|
label: d.label || `${DOC_TYPES.find(t => t.value === d.doc_type)?.label || d.doc_type}: ${vendorName}`,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Vendor Name */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Auftragsverarbeiter / Provider *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vendorName}
|
||||||
|
onChange={e => setVendorName(e.target.value)}
|
||||||
|
placeholder="z.B. SysEleven GmbH, Amazon Web Services, Microsoft"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">Dokumente</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Fuegen Sie die URLs der Vertragsdokumente hinzu. Das System erkennt den Dokumenttyp automatisch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map((entry, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="w-52 shrink-0">
|
||||||
|
<select
|
||||||
|
value={entry.doc_type}
|
||||||
|
onChange={e => updateEntry(idx, 'doc_type', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white"
|
||||||
|
>
|
||||||
|
{DOC_TYPES.map(t => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={entry.url}
|
||||||
|
onChange={e => updateEntry(idx, 'url', e.target.value)}
|
||||||
|
placeholder="https://example.com/avv.pdf"
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-44 shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entry.label}
|
||||||
|
onChange={e => updateEntry(idx, 'label', e.target.value)}
|
||||||
|
placeholder="Bezeichnung (optional)"
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{entries.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEntry(idx)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500"
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEntry}
|
||||||
|
className="mt-3 w-full py-2.5 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 text-sm"
|
||||||
|
>
|
||||||
|
+ Weiteres Dokument hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !vendorName.trim() || !entries.some(e => e.url.trim())}
|
||||||
|
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Wird gestartet...' : 'Pruefung starten'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Dokumente werden automatisch gegen Art. 28 DSGVO, Art. 32, Art. 44-49 und weitere Anforderungen geprueft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: {
|
||||||
|
vendor_name: string
|
||||||
|
documents: DocumentResult[]
|
||||||
|
findings: Finding[]
|
||||||
|
overall_score: number
|
||||||
|
category_scores: Record<string, number>
|
||||||
|
cross_check_findings: CrossCheckFinding[]
|
||||||
|
report_html: string
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentResult {
|
||||||
|
label: string
|
||||||
|
doc_type: string
|
||||||
|
completeness_pct: number
|
||||||
|
correctness_pct: number
|
||||||
|
checks: Check[]
|
||||||
|
findings_count: number
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Check {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
passed: boolean
|
||||||
|
severity: string
|
||||||
|
level: number
|
||||||
|
parent: string | null
|
||||||
|
skipped: boolean
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Finding {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
severity: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
document_label: string
|
||||||
|
document_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrossCheckFinding {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
severity: string
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_LABELS: Record<string, string> = {
|
||||||
|
INSTRUCTION: 'Weisungsgebundenheit',
|
||||||
|
CONFIDENTIALITY: 'Vertraulichkeit',
|
||||||
|
TOM: 'TOM (Art. 32)',
|
||||||
|
SUBPROCESSOR: 'Unterauftragsverarbeitung',
|
||||||
|
DATA_SUBJECT_RIGHTS: 'Betroffenenrechte',
|
||||||
|
DELETION: 'Loeschung/Rueckgabe',
|
||||||
|
AUDIT_RIGHTS: 'Audit-/Inspektionsrechte',
|
||||||
|
INCIDENT: 'Datenschutzverletzungen',
|
||||||
|
TRANSFER: 'Drittlandtransfer',
|
||||||
|
LIABILITY: 'Haftung',
|
||||||
|
AVV_CONTENT: 'AVV Inhalt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEV_COLORS: Record<string, string> = {
|
||||||
|
CRITICAL: 'bg-red-100 text-red-700 border-red-200',
|
||||||
|
HIGH: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||||
|
MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||||
|
LOW: 'bg-green-100 text-green-700 border-green-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreColor(s: number) {
|
||||||
|
if (s >= 80) return 'text-green-600'
|
||||||
|
if (s >= 50) return 'text-yellow-600'
|
||||||
|
return 'text-red-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreBg(s: number) {
|
||||||
|
if (s >= 80) return 'bg-green-50 border-green-200'
|
||||||
|
if (s >= 50) return 'bg-yellow-50 border-yellow-200'
|
||||||
|
return 'bg-red-50 border-red-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdict(s: number) {
|
||||||
|
if (s >= 80) return 'Bestanden'
|
||||||
|
if (s >= 50) return 'Bedingt bestanden'
|
||||||
|
return 'Nicht bestanden'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PruefprotokollView({ result, onReset }: Props) {
|
||||||
|
const [expandedDoc, setExpandedDoc] = useState<number | null>(null)
|
||||||
|
const [showHtml, setShowHtml] = useState(false)
|
||||||
|
|
||||||
|
const criticalCount = result.findings.filter(f => f.severity === 'CRITICAL').length
|
||||||
|
+ result.cross_check_findings.filter(f => f.severity === 'CRITICAL').length
|
||||||
|
const totalFindings = result.findings.length + result.cross_check_findings.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Score Overview */}
|
||||||
|
<div className={`rounded-xl border-2 p-8 text-center ${scoreBg(result.overall_score)}`}>
|
||||||
|
<div className="text-sm text-gray-500 mb-1">Pruefprotokoll — {result.vendor_name}</div>
|
||||||
|
<div className={`text-6xl font-extrabold ${scoreColor(result.overall_score)}`}>
|
||||||
|
{result.overall_score}%
|
||||||
|
</div>
|
||||||
|
<div className="text-lg text-gray-600 mt-1">{verdict(result.overall_score)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border p-5 text-center">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{result.documents.length}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Dokumente</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border p-5 text-center">
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{totalFindings}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Findings</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border p-5 text-center">
|
||||||
|
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Kritisch</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Scores */}
|
||||||
|
{Object.keys(result.category_scores).length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">Kategorie-Uebersicht</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(result.category_scores)
|
||||||
|
.sort(([, a], [, b]) => a - b)
|
||||||
|
.map(([cat, score]) => (
|
||||||
|
<div key={cat} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-700 w-48 truncate">{CAT_LABELS[cat] || cat}</span>
|
||||||
|
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${score >= 80 ? 'bg-green-500' : score >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||||
|
style={{ width: `${score}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-bold w-12 text-right ${scoreColor(score)}`}>{score}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cross-Check Findings */}
|
||||||
|
{result.cross_check_findings.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">
|
||||||
|
Dokumenten-Cross-Check
|
||||||
|
<span className="ml-2 text-sm font-normal text-gray-400">{result.cross_check_findings.length} Findings</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{result.cross_check_findings.map(f => (
|
||||||
|
<div key={f.id} className={`border-l-4 rounded-r-lg p-3 ${
|
||||||
|
f.severity === 'CRITICAL' ? 'border-red-500 bg-red-50' :
|
||||||
|
f.severity === 'HIGH' ? 'border-orange-500 bg-orange-50' :
|
||||||
|
'border-yellow-500 bg-yellow-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${SEV_COLORS[f.severity] || ''}`}>
|
||||||
|
{f.severity}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{f.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mt-1 leading-relaxed">{f.hint}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Documents Detail */}
|
||||||
|
<div className="bg-white rounded-xl border p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">Gepruefte Dokumente</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.documents.map((doc, i) => (
|
||||||
|
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedDoc(expandedDoc === i ? null : i)}
|
||||||
|
className="w-full text-left p-4 hover:bg-gray-50 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs font-bold">
|
||||||
|
{doc.doc_type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-900 text-sm">{doc.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`text-sm font-bold ${scoreColor(doc.completeness_pct)}`}>
|
||||||
|
{doc.completeness_pct}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-1">vollstaendig</span>
|
||||||
|
</div>
|
||||||
|
{doc.findings_count > 0 && (
|
||||||
|
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs font-bold">
|
||||||
|
{doc.findings_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400 text-sm">{expandedDoc === i ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedDoc === i && (
|
||||||
|
<div className="border-t p-4 bg-gray-50">
|
||||||
|
{doc.error ? (
|
||||||
|
<p className="text-red-600 text-sm">{doc.error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{doc.checks.filter(c => c.level === 1).map(c => {
|
||||||
|
const l2s = doc.checks.filter(l => l.level === 2 && l.parent === c.id)
|
||||||
|
return (
|
||||||
|
<div key={c.id}>
|
||||||
|
<div className={`flex items-center gap-2 py-1 ${!c.passed && !c.skipped ? 'text-red-700' : ''}`}>
|
||||||
|
<span>{c.passed ? '✓' : c.skipped ? '—' : '✗'}</span>
|
||||||
|
<span className="text-sm">{c.label}</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV_COLORS[c.severity] || ''}`}>
|
||||||
|
{c.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!c.passed && !c.skipped && c.hint && (
|
||||||
|
<div className="ml-6 mb-1 text-xs text-red-600 bg-red-50 border-l-2 border-red-300 pl-2 py-1">
|
||||||
|
{c.hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{l2s.map(l2 => (
|
||||||
|
<div key={l2.id} className={`ml-6 flex items-center gap-2 py-0.5 text-xs ${!l2.passed ? 'text-red-600' : 'text-gray-500'}`}>
|
||||||
|
<span>{l2.passed ? '✓' : l2.skipped ? '—' : '✗'}</span>
|
||||||
|
<span>{l2.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Findings Detail */}
|
||||||
|
{result.findings.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">
|
||||||
|
Alle Findings
|
||||||
|
<span className="ml-2 text-sm font-normal text-gray-400">{result.findings.length}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[...result.findings]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const order: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||||
|
return (order[a.severity] ?? 4) - (order[b.severity] ?? 4)
|
||||||
|
})
|
||||||
|
.map(f => (
|
||||||
|
<div key={f.id} className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${SEV_COLORS[f.severity] || ''}`}>
|
||||||
|
{f.severity}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{f.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mb-1">
|
||||||
|
{CAT_LABELS[f.category] || f.category} | {f.document_label}
|
||||||
|
</div>
|
||||||
|
{f.description && (
|
||||||
|
<p className="text-xs text-gray-600 leading-relaxed">{f.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Neue Pruefung
|
||||||
|
</button>
|
||||||
|
{result.report_html && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHtml(!showHtml)}
|
||||||
|
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{showHtml ? 'Protokoll ausblenden' : 'Pruefprotokoll (Druckversion)'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Print-ready HTML report */}
|
||||||
|
{showHtml && result.report_html && (
|
||||||
|
<div className="bg-white rounded-xl border p-6 mt-4">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: result.report_html }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { DocumentUploader } from './_components/DocumentUploader'
|
||||||
|
import { AssessmentProgress } from './_components/AssessmentProgress'
|
||||||
|
import { PruefprotokollView } from './_components/PruefprotokollView'
|
||||||
|
|
||||||
|
type View = 'upload' | 'progress' | 'result'
|
||||||
|
|
||||||
|
interface AssessmentResult {
|
||||||
|
vendor_name: string
|
||||||
|
documents: DocumentResult[]
|
||||||
|
findings: Finding[]
|
||||||
|
overall_score: number
|
||||||
|
category_scores: Record<string, number>
|
||||||
|
cross_check_findings: CrossCheckFinding[]
|
||||||
|
report_html: string
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentResult {
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
doc_type: string
|
||||||
|
word_count: number
|
||||||
|
completeness_pct: number
|
||||||
|
correctness_pct: number
|
||||||
|
checks: Check[]
|
||||||
|
findings_count: number
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Check {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
passed: boolean
|
||||||
|
severity: string
|
||||||
|
level: number
|
||||||
|
parent: string | null
|
||||||
|
skipped: boolean
|
||||||
|
hint: string
|
||||||
|
matched_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Finding {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
severity: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
recommendation: string
|
||||||
|
document_label: string
|
||||||
|
document_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrossCheckFinding {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
severity: string
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.NEXT_PUBLIC_COMPLIANCE_API_URL || ''
|
||||||
|
|
||||||
|
export default function VendorAssessmentPage() {
|
||||||
|
const [view, setView] = useState<View>('upload')
|
||||||
|
const [assessmentId, setAssessmentId] = useState<string>('')
|
||||||
|
const [result, setResult] = useState<AssessmentResult | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleStartAssessment = useCallback(async (
|
||||||
|
vendorName: string,
|
||||||
|
documents: Array<{ doc_type: string; label: string; url: string }>,
|
||||||
|
) => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ vendor_name: vendorName, documents }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data = await res.json()
|
||||||
|
setAssessmentId(data.assessment_id)
|
||||||
|
setView('progress')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleComplete = useCallback((data: AssessmentResult) => {
|
||||||
|
setResult(data)
|
||||||
|
setView('result')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setView('upload')
|
||||||
|
setAssessmentId('')
|
||||||
|
setResult(null)
|
||||||
|
setError('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-5xl mx-auto px-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Vertragspruefung</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Automatisierte Pruefung von Auftragsverarbeitungsvertraegen gem. Art. 28 DSGVO
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
|
<button onClick={() => setError('')} className="text-xs text-red-500 mt-1 underline">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'upload' && (
|
||||||
|
<DocumentUploader onStart={handleStartAssessment} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'progress' && assessmentId && (
|
||||||
|
<AssessmentProgress
|
||||||
|
assessmentId={assessmentId}
|
||||||
|
backendUrl={BACKEND_URL}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onError={(msg) => { setError(msg); setView('upload') }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'result' && result && (
|
||||||
|
<PruefprotokollView result={result} onReset={handleReset} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,15 +96,15 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
<AdditionalModuleItem
|
<AdditionalModuleItem
|
||||||
href="/sdk/use-case-audit"
|
href="/sdk/vendor-assessment"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
label="Use-Case Audits"
|
label="Vertragspruefung"
|
||||||
isActive={pathname?.startsWith('/sdk/use-case-audit') ?? false}
|
isActive={pathname?.startsWith('/sdk/vendor-assessment') ?? false}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""
|
||||||
|
Vendor Contract Assessment Routes — Automated vendor document analysis.
|
||||||
|
|
||||||
|
Uploads vendor contracts (AVV, SCC, TOM annex, sub-processor list),
|
||||||
|
runs them through the Doc-Check L1/L2 engine + LLM verification,
|
||||||
|
and produces a professional Pruefprotokoll.
|
||||||
|
|
||||||
|
POST /vendor-compliance/assessments — Start assessment (async)
|
||||||
|
GET /vendor-compliance/assessments — List assessments
|
||||||
|
GET /vendor-compliance/assessments/{id} — Poll status / get result
|
||||||
|
POST /vendor-compliance/assessments/{id}/approve — DSB approval
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from compliance.services.dsi_document_checker import (
|
||||||
|
check_document_completeness,
|
||||||
|
)
|
||||||
|
from compliance.services.vendor_assessment_cross_check import (
|
||||||
|
cross_check_documents,
|
||||||
|
)
|
||||||
|
from compliance.services.vendor_assessment_report import (
|
||||||
|
build_pruefprotokoll,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vendor-compliance", tags=["vendor-assessment"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request / Response Models ───────────────────────────────────────
|
||||||
|
|
||||||
|
class DocumentEntry(BaseModel):
|
||||||
|
doc_type: str = "auto" # avv, scc, tom_annex, sub_processor_list, agb, auto
|
||||||
|
label: str = ""
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentRequest(BaseModel):
|
||||||
|
vendor_name: str
|
||||||
|
documents: list[DocumentEntry]
|
||||||
|
recipient: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentStartResponse(BaseModel):
|
||||||
|
assessment_id: str
|
||||||
|
status: str = "running"
|
||||||
|
|
||||||
|
|
||||||
|
class FindingItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
category: str
|
||||||
|
severity: str
|
||||||
|
type: str # OK, GAP, RISK
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
recommendation: str = ""
|
||||||
|
document_label: str = ""
|
||||||
|
document_type: str = ""
|
||||||
|
check_id: str = ""
|
||||||
|
citations: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentResult(BaseModel):
|
||||||
|
label: str
|
||||||
|
url: str
|
||||||
|
doc_type: str
|
||||||
|
word_count: int = 0
|
||||||
|
completeness_pct: int = 0
|
||||||
|
correctness_pct: int = 0
|
||||||
|
checks: list[dict] = []
|
||||||
|
findings_count: int = 0
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentResult(BaseModel):
|
||||||
|
vendor_name: str
|
||||||
|
documents: list[DocumentResult]
|
||||||
|
findings: list[FindingItem]
|
||||||
|
overall_score: int = 0
|
||||||
|
category_scores: dict[str, int] = {}
|
||||||
|
cross_check_findings: list[dict] = []
|
||||||
|
report_html: str = ""
|
||||||
|
checked_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentStatusResponse(BaseModel):
|
||||||
|
assessment_id: str
|
||||||
|
status: str
|
||||||
|
progress: str = ""
|
||||||
|
result: Optional[AssessmentResult] = None
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── In-memory job store ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
_assessment_jobs: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/assessments", response_model=AssessmentStartResponse)
|
||||||
|
async def start_assessment(req: AssessmentRequest):
|
||||||
|
"""Start an async vendor contract assessment."""
|
||||||
|
assessment_id = str(_uuid.uuid4())
|
||||||
|
_assessment_jobs[assessment_id] = {
|
||||||
|
"status": "running",
|
||||||
|
"progress": "Initialisierung...",
|
||||||
|
"result": None,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.create_task(_run_assessment(assessment_id, req))
|
||||||
|
return AssessmentStartResponse(assessment_id=assessment_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assessments/{assessment_id}", response_model=AssessmentStatusResponse)
|
||||||
|
async def get_assessment_status(assessment_id: str):
|
||||||
|
"""Poll assessment status or retrieve completed result."""
|
||||||
|
job = _assessment_jobs.get(assessment_id)
|
||||||
|
if not job:
|
||||||
|
return AssessmentStatusResponse(
|
||||||
|
assessment_id=assessment_id, status="not_found",
|
||||||
|
error="Assessment nicht gefunden",
|
||||||
|
)
|
||||||
|
return AssessmentStatusResponse(
|
||||||
|
assessment_id=assessment_id,
|
||||||
|
status=job["status"],
|
||||||
|
progress=job.get("progress", ""),
|
||||||
|
result=job.get("result"),
|
||||||
|
error=job.get("error", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assessments")
|
||||||
|
async def list_assessments():
|
||||||
|
"""List all assessments (from in-memory store)."""
|
||||||
|
items = []
|
||||||
|
for aid, job in _assessment_jobs.items():
|
||||||
|
r = job.get("result")
|
||||||
|
items.append({
|
||||||
|
"assessment_id": aid,
|
||||||
|
"status": job["status"],
|
||||||
|
"vendor_name": r.vendor_name if r else "",
|
||||||
|
"overall_score": r.overall_score if r else 0,
|
||||||
|
"document_count": len(r.documents) if r else 0,
|
||||||
|
"findings_count": len(r.findings) if r else 0,
|
||||||
|
})
|
||||||
|
return {"assessments": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assessments/{assessment_id}/approve")
|
||||||
|
async def approve_assessment(assessment_id: str):
|
||||||
|
"""Mark an assessment as approved by DSB."""
|
||||||
|
job = _assessment_jobs.get(assessment_id)
|
||||||
|
if not job or job["status"] != "completed":
|
||||||
|
return {"error": "Assessment nicht abgeschlossen"}
|
||||||
|
job["status"] = "approved"
|
||||||
|
return {"status": "approved", "assessment_id": assessment_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Background Processing ──────────────────────────────────────────
|
||||||
|
|
||||||
|
CONSENT_TESTER_URL = "http://bp-compliance-consent-tester:8094"
|
||||||
|
|
||||||
|
# Doc-type auto-detection keywords
|
||||||
|
_DOC_TYPE_KEYWORDS = {
|
||||||
|
"avv": ["auftragsverarbeit", "auftrags-verarbeit", "data processing agreement",
|
||||||
|
"dpa ", "art. 28", "art.28", "artikel 28"],
|
||||||
|
"scc": ["standardvertragsklausel", "standard contractual clauses",
|
||||||
|
"2021/914", "klausel 14", "module 2", "modul 2"],
|
||||||
|
"tom_annex": ["technische und organisatorische", "tom-anlage",
|
||||||
|
"art. 32", "zutrittskontrolle", "zugangskontrolle",
|
||||||
|
"zugriffskontrolle", "verfuegbarkeitskontrolle"],
|
||||||
|
"sub_processor_list": ["unterauftragnehmer", "sub-processor",
|
||||||
|
"subprocessor", "unterauftragsverarbeiter"],
|
||||||
|
"agb": ["allgemeine geschaeftsbedingungen", "nutzungsbedingungen",
|
||||||
|
"terms of service", "terms and conditions"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_doc_type(text: str, label: str) -> str:
|
||||||
|
"""Auto-detect document type from content and label."""
|
||||||
|
combined = (text[:3000] + " " + label).lower()
|
||||||
|
scores: dict[str, int] = {}
|
||||||
|
for dtype, keywords in _DOC_TYPE_KEYWORDS.items():
|
||||||
|
scores[dtype] = sum(1 for kw in keywords if kw in combined)
|
||||||
|
if not scores or max(scores.values()) == 0:
|
||||||
|
return "agb" # fallback
|
||||||
|
return max(scores, key=scores.get)
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_text(url: str) -> tuple[str, int]:
|
||||||
|
"""Extract text from a URL via consent-tester or direct fetch."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Try consent-tester first (handles JS-rendered pages)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{CONSENT_TESTER_URL}/dsi-discovery",
|
||||||
|
json={"url": url, "max_documents": 1},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
docs = data.get("documents", [])
|
||||||
|
if docs:
|
||||||
|
text = docs[0].get("full_text", "")
|
||||||
|
wc = docs[0].get("word_count", 0)
|
||||||
|
if len(text) > 50:
|
||||||
|
return text, wc
|
||||||
|
# Fallback to full page
|
||||||
|
fp = data.get("html_full_page", "")
|
||||||
|
if len(fp) > 50:
|
||||||
|
return fp, len(fp.split())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("consent-tester failed for %s: %s", url, e)
|
||||||
|
|
||||||
|
# Direct fetch fallback
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
text = resp.text
|
||||||
|
return text, len(text.split())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Direct fetch failed for %s: %s", url, e)
|
||||||
|
return "", 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_assessment(assessment_id: str, req: AssessmentRequest):
|
||||||
|
"""Background task: analyze all documents and produce Pruefprotokoll."""
|
||||||
|
job = _assessment_jobs[assessment_id]
|
||||||
|
doc_results: list[DocumentResult] = []
|
||||||
|
all_findings: list[FindingItem] = []
|
||||||
|
doc_texts: dict[str, str] = {} # doc_type → text (for cross-check)
|
||||||
|
|
||||||
|
try:
|
||||||
|
total = len(req.documents)
|
||||||
|
|
||||||
|
for i, entry in enumerate(req.documents):
|
||||||
|
job["progress"] = f"Dokument {i+1}/{total}: {entry.label or entry.url[:40]}..."
|
||||||
|
|
||||||
|
# 1. Extract text
|
||||||
|
text, word_count = await _extract_text(entry.url)
|
||||||
|
if not text or len(text) < 50:
|
||||||
|
doc_results.append(DocumentResult(
|
||||||
|
label=entry.label or entry.url,
|
||||||
|
url=entry.url,
|
||||||
|
doc_type=entry.doc_type,
|
||||||
|
error="Text konnte nicht extrahiert werden",
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Detect doc_type if auto
|
||||||
|
doc_type = entry.doc_type
|
||||||
|
if doc_type == "auto":
|
||||||
|
doc_type = _detect_doc_type(text, entry.label)
|
||||||
|
logger.info("Auto-detected doc_type=%s for %s", doc_type, entry.label)
|
||||||
|
|
||||||
|
doc_texts[doc_type] = text
|
||||||
|
|
||||||
|
# 3. Run checklist
|
||||||
|
label = entry.label or f"{doc_type.upper()}: {entry.url[:50]}"
|
||||||
|
result = check_document_completeness(text, doc_type, label, entry.url)
|
||||||
|
|
||||||
|
checks = result.get("checks", [])
|
||||||
|
completeness = result.get("completeness_pct", 0)
|
||||||
|
correctness = result.get("correctness_pct", 0)
|
||||||
|
|
||||||
|
# 4. Extract findings from failed checks
|
||||||
|
failed_checks = [c for c in checks if not c.get("passed") and not c.get("skipped")]
|
||||||
|
for fc in failed_checks:
|
||||||
|
severity = fc.get("severity", "MEDIUM")
|
||||||
|
ftype = "GAP" if severity in ("CRITICAL", "HIGH") else "RISK"
|
||||||
|
|
||||||
|
all_findings.append(FindingItem(
|
||||||
|
id=f"{assessment_id[:8]}-{fc['id']}",
|
||||||
|
category=_check_to_category(fc["id"], doc_type),
|
||||||
|
severity=severity,
|
||||||
|
type=ftype,
|
||||||
|
title=fc.get("label", ""),
|
||||||
|
description=fc.get("hint", ""),
|
||||||
|
recommendation=fc.get("hint", ""),
|
||||||
|
document_label=label,
|
||||||
|
document_type=doc_type,
|
||||||
|
check_id=fc["id"],
|
||||||
|
citations=[fc.get("matched_text", "")] if fc.get("matched_text") else [],
|
||||||
|
))
|
||||||
|
|
||||||
|
doc_results.append(DocumentResult(
|
||||||
|
label=label,
|
||||||
|
url=entry.url,
|
||||||
|
doc_type=doc_type,
|
||||||
|
word_count=word_count,
|
||||||
|
completeness_pct=completeness,
|
||||||
|
correctness_pct=correctness,
|
||||||
|
checks=checks,
|
||||||
|
findings_count=len(failed_checks),
|
||||||
|
))
|
||||||
|
|
||||||
|
# 5. Cross-check between documents
|
||||||
|
job["progress"] = "Cross-Check zwischen Dokumenten..."
|
||||||
|
cross_findings = cross_check_documents(doc_texts, req.vendor_name)
|
||||||
|
|
||||||
|
# 6. Calculate scores
|
||||||
|
category_scores = _calculate_category_scores(doc_results)
|
||||||
|
overall = _calculate_overall_score(category_scores, all_findings, cross_findings)
|
||||||
|
|
||||||
|
# 7. Build result
|
||||||
|
result = AssessmentResult(
|
||||||
|
vendor_name=req.vendor_name,
|
||||||
|
documents=doc_results,
|
||||||
|
findings=all_findings,
|
||||||
|
overall_score=overall,
|
||||||
|
category_scores=category_scores,
|
||||||
|
cross_check_findings=cross_findings,
|
||||||
|
checked_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. Generate Pruefprotokoll HTML
|
||||||
|
try:
|
||||||
|
result.report_html = build_pruefprotokoll(result.model_dump())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Report generation failed: %s", e)
|
||||||
|
|
||||||
|
job["status"] = "completed"
|
||||||
|
job["progress"] = ""
|
||||||
|
job["result"] = result
|
||||||
|
logger.info("Assessment %s completed: %d docs, %d findings, score=%d%%",
|
||||||
|
assessment_id, len(doc_results), len(all_findings), overall)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Assessment %s failed", assessment_id)
|
||||||
|
job["status"] = "failed"
|
||||||
|
job["error"] = str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_to_category(check_id: str, doc_type: str) -> str:
|
||||||
|
"""Map a check ID to a finding category."""
|
||||||
|
prefix_map = {
|
||||||
|
"avv_instruction": "INSTRUCTION",
|
||||||
|
"avv_confidentiality": "CONFIDENTIALITY",
|
||||||
|
"avv_tom": "TOM",
|
||||||
|
"avv_subprocessor": "SUBPROCESSOR",
|
||||||
|
"avv_data_subject": "DATA_SUBJECT_RIGHTS",
|
||||||
|
"avv_dpia": "GENERAL",
|
||||||
|
"avv_deletion": "DELETION",
|
||||||
|
"avv_audit": "AUDIT_RIGHTS",
|
||||||
|
"avv_breach": "INCIDENT",
|
||||||
|
"avv_liability": "LIABILITY",
|
||||||
|
"avv_subject": "AVV_CONTENT",
|
||||||
|
"scc_": "TRANSFER",
|
||||||
|
"tom_": "TOM",
|
||||||
|
"sub_": "SUBPROCESSOR",
|
||||||
|
}
|
||||||
|
for prefix, cat in prefix_map.items():
|
||||||
|
if check_id.startswith(prefix):
|
||||||
|
return cat
|
||||||
|
return doc_type.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_category_scores(docs: list[DocumentResult]) -> dict[str, int]:
|
||||||
|
"""Calculate per-category compliance scores from document results."""
|
||||||
|
cat_totals: dict[str, int] = {}
|
||||||
|
cat_passed: dict[str, int] = {}
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
for check in doc.checks:
|
||||||
|
if check.get("skipped"):
|
||||||
|
continue
|
||||||
|
cat = _check_to_category(check.get("id", ""), doc.doc_type)
|
||||||
|
cat_totals[cat] = cat_totals.get(cat, 0) + 1
|
||||||
|
if check.get("passed"):
|
||||||
|
cat_passed[cat] = cat_passed.get(cat, 0) + 1
|
||||||
|
|
||||||
|
scores = {}
|
||||||
|
for cat, total in cat_totals.items():
|
||||||
|
passed = cat_passed.get(cat, 0)
|
||||||
|
scores[cat] = round(passed / total * 100) if total > 0 else 0
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_overall_score(
|
||||||
|
category_scores: dict[str, int],
|
||||||
|
findings: list[FindingItem],
|
||||||
|
cross_findings: list[dict],
|
||||||
|
) -> int:
|
||||||
|
"""Calculate overall compliance score."""
|
||||||
|
if not category_scores:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Weighted average: CRITICAL categories count double
|
||||||
|
critical_cats = {"INSTRUCTION", "TOM", "SUBPROCESSOR", "DELETION", "INCIDENT", "TRANSFER"}
|
||||||
|
total_weight = 0
|
||||||
|
weighted_sum = 0
|
||||||
|
|
||||||
|
for cat, score in category_scores.items():
|
||||||
|
weight = 2 if cat in critical_cats else 1
|
||||||
|
weighted_sum += score * weight
|
||||||
|
total_weight += weight
|
||||||
|
|
||||||
|
base = round(weighted_sum / total_weight) if total_weight > 0 else 0
|
||||||
|
|
||||||
|
# Penalty for critical findings
|
||||||
|
critical_count = sum(1 for f in findings if f.severity == "CRITICAL")
|
||||||
|
cross_critical = sum(1 for f in cross_findings if f.get("severity") == "CRITICAL")
|
||||||
|
penalty = (critical_count + cross_critical) * 5
|
||||||
|
|
||||||
|
return max(0, min(100, base - penalty))
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""
|
||||||
|
AVV (Auftragsverarbeitungsvertrag) checks — Art. 28 DSGVO.
|
||||||
|
|
||||||
|
Level 1: Pflichtklausel nach Art. 28(3) vorhanden?
|
||||||
|
Level 2: Klausel korrekt/vollstaendig formuliert?
|
||||||
|
|
||||||
|
Source: checklists-data.ts AVV_CHECKLIST → Python portiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AVV_CHECKLIST = [
|
||||||
|
# ── L1: Gegenstand und Dauer (Art. 28(3) Satz 1) ────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_subject",
|
||||||
|
"label": "Gegenstand und Dauer der Verarbeitung (Art. 28(3) S. 1)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"gegenstand\s+(?:und|&)\s+dauer\s+der\s+verarbeitung",
|
||||||
|
r"gegenstand\s+de[rs]\s+(?:auftrag|vertrag)",
|
||||||
|
r"vertragsgegenstand",
|
||||||
|
r"dauer\s+der\s+(?:auftrags)?verarbeitung",
|
||||||
|
r"laufzeit\s+de[rs]\s+(?:auftrag|vertrag)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Art. 28(3) Satz 1 DSGVO verlangt, dass der AVV den Gegenstand und die Dauer der Verarbeitung festlegt. Ohne diese Angabe ist der AVV unvollstaendig.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_subject_purpose",
|
||||||
|
"label": "Art und Zweck der Verarbeitung beschrieben",
|
||||||
|
"level": 2, "parent": "avv_subject",
|
||||||
|
"patterns": [
|
||||||
|
r"art\s+(?:und|&)\s+zweck\s+der\s+verarbeitung",
|
||||||
|
r"zweck\s+der\s+(?:daten)?verarbeitung",
|
||||||
|
r"verarbeitungszweck",
|
||||||
|
r"(?:hosting|speicherung|analyse|support|wartung)\s+(?:von|der|personenbezogener)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Die Art und der Zweck der Verarbeitung muessen konkret benannt sein (z.B. 'Hosting von Kundendaten', 'E-Mail-Versand'), nicht nur allgemein als 'Datenverarbeitung'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_subject_categories",
|
||||||
|
"label": "Kategorien betroffener Personen und Datenarten benannt",
|
||||||
|
"level": 2, "parent": "avv_subject",
|
||||||
|
"patterns": [
|
||||||
|
r"kategorie[n]?\s+(?:der\s+)?betroffene[nr]?\s+person",
|
||||||
|
r"art\s+der\s+personenbezogenen\s+daten",
|
||||||
|
r"datenkategorie",
|
||||||
|
r"(?:kunden|mitarbeiter|besch(?:ae|ä)ftigte|nutzer|bewerber)(?:daten|informationen)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Der AVV muss die Kategorien betroffener Personen (z.B. Kunden, Mitarbeiter) und die Arten personenbezogener Daten (z.B. Name, E-Mail, IP-Adresse) konkret benennen.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Weisungsgebundenheit (Art. 28(3)(a)) ────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_instruction",
|
||||||
|
"label": "Weisungsgebundenheit (Art. 28(3)(a))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"weisung(?:en|sgebunden|srecht|sbindung)",
|
||||||
|
r"(?:nur\s+)?auf\s+(?:dokumentierte\s+)?weisung\s+de[rs]\s+verantwortlichen",
|
||||||
|
r"documented\s+instructions?",
|
||||||
|
r"weisungsbefugnis",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Art. 28(3)(a) DSGVO: Der Auftragsverarbeiter darf Daten nur auf dokumentierte Weisung des Verantwortlichen verarbeiten. Ohne diese Klausel ist der AVV nichtig.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_instruction_doc",
|
||||||
|
"label": "Dokumentierte Weisungen vorgesehen",
|
||||||
|
"level": 2, "parent": "avv_instruction",
|
||||||
|
"patterns": [
|
||||||
|
r"dokumentierte\s+weisung",
|
||||||
|
r"weisung(?:en)?\s+(?:schriftlich|per\s+e-?mail|in\s+textform)",
|
||||||
|
r"textform\s+(?:gem(?:ae|ä)(?:ss|ß)|nach)\s+",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Weisungen muessen dokumentiert erfolgen (Art. 28(3)(a) DSGVO). Best Practice: Schriftform oder Textform (E-Mail genuegt), nicht nur muendlich.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_instruction_unlawful",
|
||||||
|
"label": "Hinweispflicht bei rechtswidrigen Weisungen",
|
||||||
|
"level": 2, "parent": "avv_instruction",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:rechtswidrig|unzul(?:ae|ä)ssig|rechts(?:versto(?:ss|ß)|verletzend))\w*\s+weisung",
|
||||||
|
r"hinweis(?:pflicht)?\s+(?:bei|auf)\s+(?:versto(?:ss|ß)|rechtswidr)",
|
||||||
|
r"auftragsverarbeiter\s+(?:ist\s+)?(?:verpflichtet|hat)\s+(?:den\s+verantwortlichen\s+)?(?:zu\s+)?(?:informieren|hinzuweisen|unterrichten)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Art. 28(3) Satz 3 DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen informieren, wenn eine Weisung seiner Ansicht nach gegen Datenschutzrecht verstoesst.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Vertraulichkeit (Art. 28(3)(b)) ─────────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_confidentiality",
|
||||||
|
"label": "Vertraulichkeitsverpflichtung (Art. 28(3)(b))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"vertraulichkeit(?:sverpflichtung)?",
|
||||||
|
r"verschwiegenheit(?:spflicht|sverpflichtung)?",
|
||||||
|
r"zur\s+vertraulichkeit\s+verpflichtet",
|
||||||
|
r"geheimhaltung(?:sverpflichtung)?",
|
||||||
|
r"confidentiality\s+(?:obligation|agreement)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Art. 28(3)(b) DSGVO: Alle zur Verarbeitung befugten Personen muessen zur Vertraulichkeit verpflichtet sein oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_confidentiality_employees",
|
||||||
|
"label": "Verpflichtung erstreckt sich auf Mitarbeiter",
|
||||||
|
"level": 2, "parent": "avv_confidentiality",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:mitarbeiter|besch(?:ae|ä)ftigte|personal|angestellte)\s+(?:zur|auf)\s+vertraulichkeit",
|
||||||
|
r"vertraulichkeit[\s\S]{0,100}(?:mitarbeiter|besch(?:ae|ä)ftigte|personal)",
|
||||||
|
r"verpflichtung\s+(?:der\s+)?(?:mitarbeiter|besch(?:ae|ä)ftigten)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Die Vertraulichkeitsverpflichtung muss sich auf alle Personen erstrecken, die Zugang zu den Daten haben — nicht nur vertraglich, sondern auch faktisch (z.B. Reinigungspersonal mit Zugang zu Serverraeumen).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: TOM (Art. 28(3)(c)) ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_tom",
|
||||||
|
"label": "Technische und organisatorische Massnahmen (Art. 28(3)(c))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"technische[nr]?\s+(?:und|&)\s+organisatorische[nr]?\s+ma(?:ss|ß)nahmen",
|
||||||
|
r"(?:tom|to[ms])\s*[-–:]",
|
||||||
|
r"art(?:ikel)?\s*\.?\s*32\s+(?:dsgvo|ds-?gvo|gdpr)",
|
||||||
|
r"sicherheit\s+der\s+verarbeitung",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Art. 28(3)(c) DSGVO i.V.m. Art. 32: Der AVV muss die TOM des Auftragsverarbeiters beschreiben oder auf eine Anlage verweisen. Fehlende TOM sind einer der haeufigsten Maengel bei AVV-Pruefungen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_tom_annex",
|
||||||
|
"label": "TOM-Anlage vorhanden oder referenziert",
|
||||||
|
"level": 2, "parent": "avv_tom",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:anlage|anhang|annex)\s*(?:\d|[a-z])?\s*(?:[-–:]\s*)?(?:technische|tom|sicherheit)",
|
||||||
|
r"tom[\s-]?anlage",
|
||||||
|
r"massnahmen\s+(?:gem(?:ae|ä)(?:ss|ß)|nach)\s+(?:art(?:ikel)?\s*\.?\s*)?32",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Best Practice: TOM als separate Anlage beifuegen (nicht nur im Fliesstext). Die Anlage muss konkrete Massnahmen benennen, nicht nur Art. 32-Kategorien wiederholen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_tom_update",
|
||||||
|
"label": "Aktualisierungspflicht fuer TOM vereinbart",
|
||||||
|
"level": 2, "parent": "avv_tom",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:aktualisierung|anpassung|fortschreibung|ueberarbeitung)\s+(?:der\s+)?(?:technischen|tom|massnahmen)",
|
||||||
|
r"(?:tom|massnahmen)\s+(?:regelm(?:ae|ä)(?:ss|ß)ig|jaehrlich|j(?:ae|ä)hrlich)\s+(?:ueberpruefen|aktualisieren|anpassen)",
|
||||||
|
r"stand\s+der\s+technik",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Art. 32(1) DSGVO verlangt Massnahmen 'unter Beruecksichtigung des Stands der Technik'. Der AVV sollte eine regelmaessige Aktualisierungspflicht enthalten (mindestens jaehrlich).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Unterauftragsverarbeitung (Art. 28(3)(d)) ────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_subprocessor",
|
||||||
|
"label": "Unterauftragsverarbeitung (Art. 28(3)(d))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:unter|sub)[\s-]?auftrags?(?:ver)?arbeiter",
|
||||||
|
r"sub[\s-]?processor",
|
||||||
|
r"weitere[rn]?\s+auftragsverarbeiter",
|
||||||
|
r"subunternehmer\s+(?:fuer|f(?:ue|ü)r)\s+(?:die\s+)?(?:daten)?verarbeitung",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Art. 28(2)+(3)(d) DSGVO: Ohne Genehmigungsklausel fuer Unterauftragsverarbeiter ist jeder Einsatz eines Sub-Processors rechtswidrig. Der AVV muss regeln: allgemeine oder spezifische Genehmigung, Informationspflicht, Widerspruchsrecht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_subprocessor_approval",
|
||||||
|
"label": "Genehmigungserfordernis (allgemein oder spezifisch)",
|
||||||
|
"level": 2, "parent": "avv_subprocessor",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:vorherige|schriftliche|allgemeine|spezifische)\s+(?:schriftliche\s+)?genehmigung",
|
||||||
|
r"zustimmung\s+(?:des\s+verantwortlichen|zur\s+(?:unter|sub))",
|
||||||
|
r"(?:einwilligung|genehmigung)\s+(?:des\s+verantwortlichen|vor(?:ab|her))",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Art. 28(2) DSGVO: Entweder spezifische (namentlich) oder allgemeine (pauschal + Informationspflicht) Genehmigung. Bei allgemeiner Genehmigung MUSS der AV ueber Aenderungen informieren.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_subprocessor_objection",
|
||||||
|
"label": "Widerspruchsrecht bei Aenderung der Unterauftragnehmer",
|
||||||
|
"level": 2, "parent": "avv_subprocessor",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:widerspruch|einspruch|einwand)(?:srecht|smoeglichkeit)?[\s\S]{0,100}(?:unter|sub)[\s-]?auftrags?",
|
||||||
|
r"(?:unter|sub)[\s-]?auftrags?[\s\S]{0,200}(?:widerspruch|einspruch|ablehnung)",
|
||||||
|
r"(?:informieren|benachrichtigen|mitteilen)[\s\S]{0,100}(?:aenderung|wechsel|einsatz)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Art. 28(2) Satz 2 DSGVO: Bei allgemeiner Genehmigung muss der Verantwortliche ueber jeden neuen Unterauftragsverarbeiter informiert werden und die Moeglichkeit haben, Einspruch zu erheben.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_subprocessor_list",
|
||||||
|
"label": "Aktuelle Liste der Unterauftragnehmer",
|
||||||
|
"level": 2, "parent": "avv_subprocessor",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:liste|verzeichnis|uebersicht|aufstellung)\s+(?:der\s+)?(?:aktuellen\s+)?(?:unter|sub)[\s-]?auftrags?",
|
||||||
|
r"(?:anlage|anhang|annex)\s*\d?\s*[-–:]\s*(?:unter|sub)[\s-]?auftrags?",
|
||||||
|
r"(?:unter|sub)[\s-]?auftrags?[\s\S]{0,100}(?:anlage|anhang|liste)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Best Practice: Aktuelle Sub-Processor-Liste als Anlage zum AVV. Sollte enthalten: Name, Anschrift, Land, Verarbeitungszweck, Datenkategorien. Viele Aufsichtsbehoerden fordern dies explizit.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Betroffenenrechte (Art. 28(3)(e)) ────────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_data_subject_rights",
|
||||||
|
"label": "Unterstuetzung bei Betroffenenrechten (Art. 28(3)(e))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"betroffenenrecht",
|
||||||
|
r"(?:unterstuetzung|mithilfe|mitwirkung)\s+(?:bei|zur)\s+(?:erf(?:ue|ü)llung|wahrnehmung|gew(?:ae|ä)hrleistung)\s+(?:der\s+)?(?:rechte|pflichten)",
|
||||||
|
r"(?:auskunft|l(?:oe|ö)schung|berichtigung|einschr(?:ae|ä)nkung|daten(?:ue|ü)bertragbarkeit|widerspruch)",
|
||||||
|
r"art(?:ikel)?\s*\.?\s*(?:15|16|17|18|19|20|21|22)\s+(?:dsgvo|ds-?gvo)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Art. 28(3)(e) DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen bei der Erfuellung der Betroffenenrechte (Art. 15-22) unterstuetzen — insbesondere bei Auskunfts- und Loeschungsanfragen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_data_subject_forwarding",
|
||||||
|
"label": "Weiterleitung von Betroffenenanfragen geregelt",
|
||||||
|
"level": 2, "parent": "avv_data_subject_rights",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:weiterleitung|weiterleiten|informieren)\s+(?:von\s+)?(?:anfragen|antraegen)\s+(?:betroffener|von\s+betroffenen)",
|
||||||
|
r"(?:betroffene|datensubjekt)[\s\S]{0,150}(?:weiterleiten|informieren|benachrichtigen)",
|
||||||
|
r"(?:anfrage|antrag|ersuchen)\s+(?:des|der|von)\s+(?:betroffenen|datensubjekt)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Der AVV sollte regeln: Wer leitet Betroffenenanfragen weiter? In welcher Frist? Wer antwortet dem Betroffenen?",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: DSFA-Unterstuetzung (Art. 28(3)(f)) ─────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_dpia",
|
||||||
|
"label": "Unterstuetzung bei DSFA und Konsultation (Art. 28(3)(f))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"datenschutz[\s-]?folgenabsch(?:ae|ä)tzung",
|
||||||
|
r"dsfa",
|
||||||
|
r"(?:dpia|data\s+protection\s+impact)",
|
||||||
|
r"art(?:ikel)?\s*\.?\s*(?:35|36)\s+(?:dsgvo|ds-?gvo)",
|
||||||
|
r"(?:unterstuetzung|mitwirkung)\s+(?:bei\s+)?(?:der\s+)?(?:einhaltung|erfuellung)\s+(?:der\s+)?(?:pflichten\s+)?(?:gem(?:ae|ä)(?:ss|ß)|nach)\s+art(?:ikel)?\s*\.?\s*(?:32|33|34|35|36)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Art. 28(3)(f) DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen bei der Durchfuehrung einer DSFA und bei der vorherigen Konsultation der Aufsichtsbehoerde (Art. 36) unterstuetzen.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Loeschung/Rueckgabe (Art. 28(3)(g)) ─────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_deletion",
|
||||||
|
"label": "Loeschung/Rueckgabe nach Vertragsende (Art. 28(3)(g))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:l(?:oe|ö)schung|rueckgabe|r(?:ue|ü)ckgabe)\s+(?:nach|bei|zum)\s+(?:vertragsende|beendigung|ablauf)",
|
||||||
|
r"(?:nach|bei)\s+(?:beendigung|ablauf|ende)\s+(?:des\s+)?(?:vertrag|auftrag)[\s\S]{0,100}(?:l(?:oe|ö)sch|rueckgabe|r(?:ue|ü)ckgabe|vernicht)",
|
||||||
|
r"(?:alle|saemtliche)\s+(?:personenbezogenen?\s+)?daten\s+(?:l(?:oe|ö)sch|vernicht|zurueckgeb|zur(?:ue|ü)ckgeb)",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Art. 28(3)(g) DSGVO: Nach Ende der Verarbeitung muessen alle personenbezogenen Daten geloescht oder zurueckgegeben werden — nach Wahl des Verantwortlichen. Ausnahme nur bei gesetzlicher Aufbewahrungspflicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_deletion_deadline",
|
||||||
|
"label": "Frist fuer Loeschung/Rueckgabe definiert",
|
||||||
|
"level": 2, "parent": "avv_deletion",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:\d+\s+(?:tage|werktage|wochen|monate)|(?:innerhalb|binnen)\s+(?:von\s+)?\d+)\s*[\s\S]{0,50}(?:l(?:oe|ö)sch|rueckgabe|r(?:ue|ü)ckgabe)",
|
||||||
|
r"(?:l(?:oe|ö)sch|rueckgabe|r(?:ue|ü)ckgabe)[\s\S]{0,100}(?:frist|innerhalb|binnen)\s+(?:von\s+)?\d+",
|
||||||
|
r"(?:unverz(?:ue|ü)glich|sofort|sp(?:ae|ä)testens)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Best Practice: Loeschfrist von max. 30 Tagen nach Vertragsende. Viele Aufsichtsbehoerden beanstanden AVVs ohne konkrete Frist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_deletion_confirmation",
|
||||||
|
"label": "Loeschbestaetigung/Nachweis vorgesehen",
|
||||||
|
"level": 2, "parent": "avv_deletion",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:l(?:oe|ö)sch|vernichtungs?)(?:best(?:ae|ä)tigung|nachweis|protokoll|zertifikat)",
|
||||||
|
r"(?:best(?:ae|ä)tigung|nachweis)\s+(?:der|ueber)\s+(?:die\s+)?(?:l(?:oe|ö)schung|vernichtung)",
|
||||||
|
r"schriftlich\s+best(?:ae|ä)tigen[\s\S]{0,50}(?:l(?:oe|ö)sch|vernicht)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Best Practice: Schriftliche Loeschbestaetigung mit Datum und Umfang anfordern. Einige Aufsichtsbehoerden (z.B. LfDI BaWue) fordern dies als Nachweis der Pflichterfuellung.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Audit-/Inspektionsrechte (Art. 28(3)(h)) ────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_audit",
|
||||||
|
"label": "Audit- und Inspektionsrechte (Art. 28(3)(h))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:audit|inspektion|ueberpruefung|ueberpr(?:ue|ü)fung|kontrolle|pruefung|pr(?:ue|ü)fung)(?:s)?(?:recht|rechte|moeglichkeit|befugnis)",
|
||||||
|
r"(?:vor[\s-]?ort|on[\s-]?site)[\s-]?(?:inspektion|kontrolle|pruefung|pr(?:ue|ü)fung)",
|
||||||
|
r"art(?:ikel)?\s*\.?\s*28\s*(?:\(3\)|abs(?:atz)?\s*\.?\s*3)\s*(?:(?:lit(?:era)?\s*\.?\s*)?h|buchst(?:abe)?\s*\.?\s*h)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Art. 28(3)(h) DSGVO: Der Auftragsverarbeiter muss Ueberpruefungen (Audits, Inspektionen) durch den Verantwortlichen ermoeglichen und dazu beitragen. Dieses Recht darf vertraglich nicht ausgeschlossen oder unangemessen eingeschraenkt werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_audit_onsite",
|
||||||
|
"label": "Vor-Ort-Inspektionen moeglich",
|
||||||
|
"level": 2, "parent": "avv_audit",
|
||||||
|
"patterns": [
|
||||||
|
r"vor[\s-]?ort[\s-]?(?:inspektion|kontrolle|pruefung|pr(?:ue|ü)fung|besichtigung)",
|
||||||
|
r"(?:inspektion|kontrolle|pruefung|pr(?:ue|ü)fung)\s+vor\s+ort",
|
||||||
|
r"(?:zugang|zutritt)\s+(?:zu\s+)?(?:den\s+)?(?:raeumen|r(?:ae|ä)umlichkeiten|betriebsstaetten|rechenzentren)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Vor-Ort-Inspektionen sind ein Kernrecht nach Art. 28(3)(h) DSGVO. Einschraenkungen auf 'Remote Audits' oder 'nur Zertifikate' genuegen laut EuGH-Rechtsprechung nicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_audit_independent",
|
||||||
|
"label": "Akzeptanz unabhaengiger Pruefer",
|
||||||
|
"level": 2, "parent": "avv_audit",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:unabh(?:ae|ä)ngig|extern|dritte)\w*\s+(?:pruefer|pr(?:ue|ü)fer|auditor|sachverstaendig|gutachter|wirtschaftspruefer)",
|
||||||
|
r"(?:pruefer|pr(?:ue|ü)fer|auditor)\s+(?:des\s+verantwortlichen|des\s+auftraggebers|beauftragt)",
|
||||||
|
],
|
||||||
|
"severity": "LOW",
|
||||||
|
"hint": "Best Practice: AVV sollte ausdruecklich erwaehnen, dass der Verantwortliche auch unabhaengige Pruefer beauftragen kann. Dies ist besonders wichtig bei grossen Cloud-Anbietern (z.B. SOC2-Berichte als Ersatz).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Datenschutzverletzungen (Art. 33(2)) ────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_breach",
|
||||||
|
"label": "Meldung von Datenschutzverletzungen (Art. 33(2))",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"datenschutzverletzung",
|
||||||
|
r"(?:sicherheits)?vorfall",
|
||||||
|
r"data\s+breach",
|
||||||
|
r"(?:meld|benachrichtig|informier|unterricht)\w*[\s\S]{0,50}(?:verletzung|vorfall|sicherheit)",
|
||||||
|
r"art(?:ikel)?\s*\.?\s*33\s+(?:dsgvo|ds-?gvo)",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Art. 33(2) DSGVO: Der Auftragsverarbeiter muss den Verantwortlichen UNVERZUEGLICH ueber jede Datenschutzverletzung informieren. Die 72-Stunden-Frist des Verantwortlichen gegenueber der Aufsichtsbehoerde laeuft ab Kenntnis — daher sollte die Meldefrist im AVV enger sein (z.B. 24h).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_breach_timeline",
|
||||||
|
"label": "Meldefrist fuer Datenschutzverletzungen definiert",
|
||||||
|
"level": 2, "parent": "avv_breach",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:unverz(?:ue|ü)glich|ohne\s+(?:unangemessene|ungebuerliche)\s+verzoegerung|sofort|binnen\s+\d+\s+stunden|innerhalb\s+(?:von\s+)?\d+\s+stunden)",
|
||||||
|
r"\d+\s*(?:h|stunden|hours?)[\s\S]{0,50}(?:meld|benachrichtig|informier)",
|
||||||
|
r"(?:24|48|72)\s*(?:h|stunden)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Best Practice: Meldefrist von max. 24 Stunden nach Kenntnis (nicht 72h — das ist die Frist des Verantwortlichen gegenueber der Behoerde). DSK empfiehlt in den Standard-AVV-Klauseln 'unverzueglich, spaetestens innerhalb von 48 Stunden'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "avv_breach_content",
|
||||||
|
"label": "Mindestinhalt der Meldung definiert",
|
||||||
|
"level": 2, "parent": "avv_breach",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:mindestinhalt|mindestangaben|informationen|inhalt)\s+(?:der|einer|zur)\s+(?:meldung|benachrichtigung|mitteilung)",
|
||||||
|
r"(?:art\s+der\s+verletzung|betroffene\s+datenkategorien|anzahl\s+betroffener|wahrscheinliche\s+folgen|ergriffene\s+massnahmen)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Die Meldung sollte mindestens enthalten: Art der Verletzung, betroffene Datenkategorien, ungefaehre Anzahl Betroffener, wahrscheinliche Folgen, ergriffene Abhilfemassnahmen (Art. 33(3) DSGVO).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Haftung ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "avv_liability",
|
||||||
|
"label": "Haftungsregelung",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"haftung(?:s)?(?:regelung|beschraenkung|begrenzung|verteilung)?",
|
||||||
|
r"schadensersatz",
|
||||||
|
r"(?:freistellung|indemnit)",
|
||||||
|
r"art(?:ikel)?\s*\.?\s*82\s+(?:dsgvo|ds-?gvo)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Art. 82 DSGVO regelt die Haftung bei Datenschutzverstoessen. Der AVV sollte eine klare Haftungsverteilung enthalten. Achtung: Haftungsausschluesse gegenueber Betroffenen sind unwirksam (Art. 82(4) DSGVO).",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -16,6 +16,10 @@ 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
|
from .eu_institution_checks import EU_INSTITUTION_CHECKLIST
|
||||||
|
from .avv_checks import AVV_CHECKLIST
|
||||||
|
from .scc_checks import SCC_CHECKLIST
|
||||||
|
from .tom_annex_checks import TOM_ANNEX_CHECKLIST
|
||||||
|
from .sub_processor_checks import SUB_PROCESSOR_LIST_CHECKLIST
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +41,17 @@ _CHECKLIST_MAP = {
|
|||||||
"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"),
|
"eu_institution": (EU_INSTITUTION_CHECKLIST, "VO (EU) 2018/1725"),
|
||||||
|
"avv": (AVV_CHECKLIST, "Art. 28 DSGVO"),
|
||||||
|
"auftragsverarbeitung": (AVV_CHECKLIST, "Art. 28 DSGVO"),
|
||||||
|
"dpa": (AVV_CHECKLIST, "Art. 28 DSGVO"),
|
||||||
|
"scc": (SCC_CHECKLIST, "EU SCC 2021"),
|
||||||
|
"standardvertragsklauseln": (SCC_CHECKLIST, "EU SCC 2021"),
|
||||||
|
"tom_annex": (TOM_ANNEX_CHECKLIST, "Art. 32 DSGVO"),
|
||||||
|
"tom_anlage": (TOM_ANNEX_CHECKLIST, "Art. 32 DSGVO"),
|
||||||
|
"tom": (TOM_ANNEX_CHECKLIST, "Art. 32 DSGVO"),
|
||||||
|
"sub_processor_list": (SUB_PROCESSOR_LIST_CHECKLIST, "Art. 28(3)(d) DSGVO"),
|
||||||
|
"sub_processor": (SUB_PROCESSOR_LIST_CHECKLIST, "Art. 28(3)(d) DSGVO"),
|
||||||
|
"unterauftragnehmer": (SUB_PROCESSOR_LIST_CHECKLIST, "Art. 28(3)(d) DSGVO"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
SCC (Standardvertragsklauseln / Standard Contractual Clauses) checks.
|
||||||
|
|
||||||
|
EU Commission Decision 2021/914 — the "new" SCCs.
|
||||||
|
|
||||||
|
Level 1: Pflichtbestandteil vorhanden?
|
||||||
|
Level 2: Bestandteil korrekt ausgefuellt?
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCC_CHECKLIST = [
|
||||||
|
# ── L1: Modul-Wahl ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "scc_module",
|
||||||
|
"label": "SCC-Modul gewaehlt (C2C, C2P, P2C, P2P)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"modul\s*(?:1|2|3|4|eins|zwei|drei|vier|i{1,3}v?)\s*[-–:.]",
|
||||||
|
r"(?:module\s+(?:one|two|three|four|[1-4]))",
|
||||||
|
r"(?:controller|processor)\s+to\s+(?:controller|processor)",
|
||||||
|
r"verantwortliche[rn]?\s+(?:an|zu)\s+(?:auftragsverarbeiter|verantwortliche)",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Die EU SCC 2021 bestehen aus 4 Modulen. Das richtige Modul MUSS gewaehlt werden: Modul 1 (C2C), Modul 2 (C2P — haeufigster Fall), Modul 3 (P2P), Modul 4 (P2C). Falsches Modul = unwirksame SCC.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Annex I — Vertragsparteien + Transfer ───────────────────
|
||||||
|
{
|
||||||
|
"id": "scc_annex_i",
|
||||||
|
"label": "Annex I: Vertragsparteien und Uebermittlung beschrieben",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:anhang|anlage|annex)\s*i\b",
|
||||||
|
r"(?:list\s+of\s+parties|verzeichnis\s+der\s+(?:vertrags)?parteien)",
|
||||||
|
r"(?:daten(?:ex|im)porteur|data\s+(?:ex|im)porter)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Annex I der SCC muss benennen: Datenexporteur (Name, Adresse, Kontakt, Rolle), Datenimporteur (Name, Adresse, Kontakt, Rolle), Beschreibung der Uebermittlung (Kategorien, Empfaenger, Zweck, Dauer).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "scc_annex_i_parties",
|
||||||
|
"label": "Vertragsparteien identifiziert (Exporteur + Importeur)",
|
||||||
|
"level": 2, "parent": "scc_annex_i",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:daten)?exporteur|data\s+exporter",
|
||||||
|
r"(?:daten)?importeur|data\s+importer",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Beide Parteien muessen vollstaendig identifiziert sein: Name, Adresse, Ansprechpartner, Rolle (Controller/Processor).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Annex II — TOM ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "scc_annex_ii",
|
||||||
|
"label": "Annex II: Technische und organisatorische Massnahmen",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:anhang|anlage|annex)\s*ii\b",
|
||||||
|
r"technische[\s\S]{0,30}organisatorische[\s\S]{0,30}ma(?:ss|ß)nahmen[\s\S]{0,50}(?:anhang|anlage|annex)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Annex II muss konkrete TOM beschreiben (nicht nur auf den Hauptvertrag verweisen). Die Massnahmen muessen dem Risiko der Uebermittlung angemessen sein.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Annex III — Sub-Processors ───────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "scc_annex_iii",
|
||||||
|
"label": "Annex III: Liste der Unterauftragsverarbeiter (bei Modul 2/3)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:anhang|anlage|annex)\s*iii\b",
|
||||||
|
r"(?:liste|verzeichnis)\s+(?:der\s+)?(?:unter|sub)[\s-]?auftrags?",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Bei Modul 2 (C2P) und Modul 3 (P2P): Annex III muss alle Sub-Processors auflisten mit Name, Adresse, Taetigkeit. Bei Modul 1 (C2C) und 4 (P2C) nicht erforderlich.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Transfer Impact Assessment (TIA) ────────────────────────
|
||||||
|
{
|
||||||
|
"id": "scc_tia",
|
||||||
|
"label": "Transfer Impact Assessment (TIA) durchgefuehrt/referenziert",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"transfer\s+impact\s+assessment",
|
||||||
|
r"(?:uebermittlungs|transfer)[\s-]?(?:risiko|folgen)[\s-]?(?:bewertung|abschaetzung|analyse)",
|
||||||
|
r"(?:risikobewertung|risikoanalyse)[\s\S]{0,100}(?:drittland|uebermittlung|transfer)",
|
||||||
|
r"klausel\s*14\b|clause\s*14\b",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Klausel 14 der SCC verlangt ein Transfer Impact Assessment (TIA): Analyse der Rechtslage im Zielland, insbesondere Zugriffsbefugnisse der Behoerden. Ohne TIA sind die SCC unvollstaendig (EuGH Schrems II, C-311/18).",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Keine unzulaessigen Aenderungen ─────────────────────────
|
||||||
|
{
|
||||||
|
"id": "scc_no_modification",
|
||||||
|
"label": "Kernklauseln unmodifiziert (keine unzulaessigen Aenderungen)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:standardvertragsklauseln|standard\s+contractual\s+clauses)[\s\S]{0,200}(?:unver(?:ae|ä)ndert|nicht\s+ge(?:ae|ä)ndert|2021/914|durchfuehrungsbeschluss)",
|
||||||
|
r"(?:durchf(?:ue|ü)hrungsbeschluss|implementing\s+decision)\s+(?:\(EU\)\s+)?2021/914",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Die Kernklauseln der SCC (Klauseln 1-18) duerfen nicht geaendert werden. Ergaenzende Klauseln sind erlaubt, solange sie nicht im Widerspruch stehen. Geaenderte SCC sind unwirksam.",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Sub-Processor List checks — Art. 28(3)(d) DSGVO.
|
||||||
|
|
||||||
|
Prueft ob die Sub-Processor-Liste vollstaendig und
|
||||||
|
konform strukturiert ist.
|
||||||
|
|
||||||
|
Level 1: Pflichtangabe vorhanden?
|
||||||
|
Level 2: Angabe konkret genug?
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUB_PROCESSOR_LIST_CHECKLIST = [
|
||||||
|
# ── L1: Tabellen-/Listenstruktur ─────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "sub_structure",
|
||||||
|
"label": "Strukturierte Liste (Tabelle/Auflistung)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:unterauftragnehmer|sub[\s-]?processor|unterauftragsverarbeiter)[\s\S]{0,200}(?:name|firma|unternehmen)",
|
||||||
|
r"(?:nr|#|\d+)\s*[.)\]]\s+(?:name|firma|unternehmen)\s*[.:]\s*\w",
|
||||||
|
r"(?:name|firma)\s+(?:sitz|standort|adresse|land|zweck|leistung)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Die Sub-Processor-Liste muss strukturiert sein (Tabelle oder nummerierte Liste), nicht nur als Fliesstext. Jeder Eintrag sollte klar abgegrenzt sein.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Name des Sub-Processors ──────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "sub_name",
|
||||||
|
"label": "Name/Firma jedes Sub-Processors angegeben",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:name|firma|unternehmen)\s*[.:]\s*\w{2,}",
|
||||||
|
r"(?:gmbh|ag|inc|llc|ltd|se|sarl|bv|corp)\b",
|
||||||
|
],
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"hint": "Jeder Sub-Processor muss namentlich (vollstaendiger Firmenname mit Rechtsform) identifiziert werden. Unbestimmte Angaben wie 'diverse IT-Dienstleister' genuegen nicht.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Sitz/Land ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "sub_location",
|
||||||
|
"label": "Sitz/Land jedes Sub-Processors angegeben",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:sitz|standort|land|laender|country)\s*[.:]\s*(?:deutschland|usa|irland|niederlande|frankreich|eu|ewr|germany|ireland|united\s+states|netherlands)",
|
||||||
|
r"(?:frankfurt|dublin|amsterdam|paris|london|seattle|virginia|california|oregon)",
|
||||||
|
r"(?:de|us|ie|nl|fr|gb|at|ch)\b[\s,]",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Fuer jeden Sub-Processor muss der Sitz (Land, ggf. Stadt) angegeben werden. Bei Drittlandtransfer (nicht-EU/EWR) muss der Transfermechanismus dokumentiert sein.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sub_location_thirdcountry",
|
||||||
|
"label": "Drittlandtransfer identifiziert und Mechanismus benannt",
|
||||||
|
"level": 2, "parent": "sub_location",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:usa|united\s+states|indien|india|china|japan|australi|brasil|canad|israel|south\s+korea|singapur|singapore)",
|
||||||
|
r"(?:scc|standardvertragsklausel|data\s+privacy\s+framework|dpf|angemessenheitsbeschluss|adequacy)",
|
||||||
|
r"(?:drittland|third\s+country|nicht[\s-]?(?:eu|ewr))",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Sub-Processor in Drittlaendern (z.B. USA) benoetigen einen gueltige Transfermechanismus: EU-US Data Privacy Framework (DPF), SCC, oder Angemessenheitsbeschluss. Ohne Mechanismus ist der Transfer rechtswidrig.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Verarbeitungszweck ───────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "sub_purpose",
|
||||||
|
"label": "Verarbeitungszweck pro Sub-Processor beschrieben",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:zweck|leistung|taetigkeit|aufgabe|dienstleistung|beschreibung)\s*[.:]\s*\w{3,}",
|
||||||
|
r"(?:hosting|speicherung|support|wartung|monitoring|logging|e[\s-]?mail|crm|analytics|cdn|backup|payment)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Fuer jeden Sub-Processor muss der konkrete Verarbeitungszweck angegeben werden (z.B. 'Hosting der Kundendatenbank', 'E-Mail-Versand'). Allgemeine Angaben wie 'IT-Services' genuegen nicht.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Datenkategorien ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "sub_data_categories",
|
||||||
|
"label": "Verarbeitete Datenkategorien pro Sub-Processor",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:daten(?:kategorie|art)n?|art\s+der\s+(?:personenbezogenen\s+)?daten)\s*[.:]\s*\w",
|
||||||
|
r"(?:name|e[\s-]?mail|ip[\s-]?adresse|kundendaten|nutzungsdaten|bestandsdaten|kontaktdaten|zahlungsdaten)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Best Practice: Pro Sub-Processor angeben welche Datenkategorien verarbeitet werden (z.B. 'E-Mail-Adressen, IP-Adressen'). Erleichtert die Risikoeinschaetzung und die Beantwortung von Betroffenenanfragen.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Aktualitaet ─────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "sub_date",
|
||||||
|
"label": "Datum/Stand der Liste angegeben",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"(?:stand|datum|version|aktualisiert|letzte\s+(?:ae|ä)nderung|g(?:ue|ü)ltig\s+(?:ab|seit))\s*[.:]\s*\d{1,2}[./]\d{1,2}[./]\d{2,4}",
|
||||||
|
r"(?:stand|version|vom)\s*[.:]\s*(?:januar|februar|maerz|april|mai|juni|juli|august|september|oktober|november|dezember|january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{4}",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Die Sub-Processor-Liste muss ein Datum/Stand enthalten. Veraltete Listen (>12 Monate) sind ein haeufiger Beanstandungsgrund bei Audits.",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
TOM-Anlage (Technische und Organisatorische Massnahmen) checks — Art. 32 DSGVO.
|
||||||
|
|
||||||
|
Prueft die 8 klassischen Kontrollziele nach dem DSK-Standard
|
||||||
|
sowie die Art. 32(1) Anforderungen.
|
||||||
|
|
||||||
|
Level 1: Kontrollziel adressiert?
|
||||||
|
Level 2: Konkrete Massnahme beschrieben?
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOM_ANNEX_CHECKLIST = [
|
||||||
|
# ── L1: Zutrittskontrolle ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_access_physical",
|
||||||
|
"label": "Zutrittskontrolle (physischer Zugang)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"zutrittskontrolle",
|
||||||
|
r"physische[rn]?\s+(?:zugang|sicherheit|zutrittskontrolle)",
|
||||||
|
r"geb(?:ae|ä)ude(?:sicherheit|zugang|zutritt)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Zutrittskontrolle: Unbefugten den physischen Zugang zu Datenverarbeitungsanlagen verwehren. Beispiele: Schliessanlage, Chipkarte, Videoüberwachung, Besucherregelung.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tom_access_physical_measures",
|
||||||
|
"label": "Konkrete Zutrittsmassnahmen benannt",
|
||||||
|
"level": 2, "parent": "tom_access_physical",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:schl(?:ue|ü)ssel|chipkarte|badge|code|pin|biometr|video(?:ue|ü)berwachung|alarm|pfortner|empfang|besucher)",
|
||||||
|
r"(?:zutrittskontroll|zugangs)(?:system|anlage|konzept)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Nicht nur 'Zutrittskontrolle vorhanden' schreiben — konkrete Massnahmen benennen (z.B. 'elektronisches Schliesssystem mit personenbezogenen Chipkarten, protokollierte Besucherregelung').",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Zugangskontrolle ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_access_logical",
|
||||||
|
"label": "Zugangskontrolle (IT-Systeme)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"zugangskontrolle",
|
||||||
|
r"(?:authentifizierung|authentisierung|login|anmeldung)\s+(?:zu|an|fuer|bei)\s+(?:systemen|it-?systemen|anwendungen)",
|
||||||
|
r"(?:passwort|kennwort)[\s-]?(?:richtlinie|policy|politik|regelung)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Zugangskontrolle: Unbefugten den Zugang zu IT-Systemen verwehren. Beispiele: Passwortrichtlinie, MFA, automatische Sperrung, VPN.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tom_access_logical_mfa",
|
||||||
|
"label": "Multi-Faktor-Authentifizierung oder starke Passwoerter",
|
||||||
|
"level": 2, "parent": "tom_access_logical",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:multi[\s-]?faktor|zwei[\s-]?faktor|2[\s-]?fa|mfa|totp|fido|yubikey)",
|
||||||
|
r"(?:passwort|kennwort)[\s\S]{0,100}(?:mindestens\s+\d+\s+zeichen|komplex|stark|sicher)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "BSI empfiehlt MFA fuer alle Administratorzugaenge. Mindestens starke Passwoerter (12+ Zeichen, Komplexitaetsanforderungen) muessen dokumentiert sein.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Zugriffskontrolle ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_authorization",
|
||||||
|
"label": "Zugriffskontrolle (Berechtigungen)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"zugriffskontrolle",
|
||||||
|
r"(?:berechtigungs|rechte|rollen)(?:konzept|management|vergabe|system)",
|
||||||
|
r"(?:need[\s-]?to[\s-]?know|least\s+privilege|minimalprinzip)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Zugriffskontrolle: Sicherstellen, dass Benutzer nur auf die Daten zugreifen koennen, fuer die sie berechtigt sind. Beispiele: Rollenkonzept (RBAC), Need-to-Know-Prinzip, regelmaessige Rechterezertifizierung.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Weitergabekontrolle / Uebertragungssicherheit ───────────
|
||||||
|
{
|
||||||
|
"id": "tom_transfer",
|
||||||
|
"label": "Weitergabekontrolle / Verschluesselung bei Transport",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"weitergabekontrolle",
|
||||||
|
r"(?:ue|ü)bertragungssicherheit",
|
||||||
|
r"(?:transport|uebertragung|transit)[\s-]?verschl(?:ue|ü)sselung",
|
||||||
|
r"(?:tls|ssl|https|sftp|vpn|ipsec)\s",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Weitergabekontrolle: Personenbezogene Daten duerfen bei elektronischer Uebertragung nicht unbefugt gelesen, kopiert oder veraendert werden. Beispiele: TLS 1.2+, VPN, verschluesselter E-Mail-Versand.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tom_transfer_encryption",
|
||||||
|
"label": "Verschluesselungsstandard benannt (TLS, AES etc.)",
|
||||||
|
"level": 2, "parent": "tom_transfer",
|
||||||
|
"patterns": [
|
||||||
|
r"(?:tls|ssl)\s*(?:1\.[2-3]|1\.3)",
|
||||||
|
r"aes[\s-]?(?:128|256)",
|
||||||
|
r"verschl(?:ue|ü)sselung[\s\S]{0,100}(?:bit|aes|rsa|ecc|sha)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Konkrete Standards benennen: TLS 1.2 oder hoeher (nicht 'SSL'), AES-256 fuer Verschluesselung at Rest. Stand der Technik nach Art. 32(1) DSGVO.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Eingabekontrolle ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_input",
|
||||||
|
"label": "Eingabekontrolle (Protokollierung)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"eingabekontrolle",
|
||||||
|
r"(?:protokollierung|logging|audit[\s-]?(?:log|trail|protokoll))",
|
||||||
|
r"(?:nachvollziehbar|nachvollziehbarkeit)\s+(?:der\s+)?(?:eingabe|aenderung|loeschung|verarbeitung)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Eingabekontrolle: Nachtraeglich pruefen koennen, wer wann welche Daten eingegeben, geaendert oder geloescht hat. Beispiele: Audit-Logging, Versionierung, Zugriffsprotokollierung.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Auftragskontrolle ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_processing_control",
|
||||||
|
"label": "Auftragskontrolle (Weisungsgebundenheit)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"auftragskontrolle",
|
||||||
|
r"(?:weisung|anweisung)(?:sgebunden|en)\s+(?:der\s+)?(?:mitarbeiter|besch(?:ae|ä)ftigten)",
|
||||||
|
r"(?:schulung|sensibilisierung)\s+(?:der\s+)?(?:mitarbeiter|besch(?:ae|ä)ftigten)\s+(?:zum|im|bezueglich)\s+(?:datenschutz|umgang\s+mit\s+daten)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Auftragskontrolle: Sicherstellen, dass personenbezogene Daten nur entsprechend den Weisungen verarbeitet werden. Beispiele: Mitarbeiterschulungen, Verpflichtung auf Datengeheimnis.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Verfuegbarkeitskontrolle ─────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_availability",
|
||||||
|
"label": "Verfuegbarkeitskontrolle (Backup, Wiederherstellung)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"verf(?:ue|ü)gbarkeit(?:skontrolle)?",
|
||||||
|
r"(?:backup|datensicherung|wiederherstellung|disaster\s+recovery|business\s+continuity)",
|
||||||
|
r"(?:redundanz|spiegelung|replikation|georedundant)",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Verfuegbarkeitskontrolle: Personenbezogene Daten gegen Verlust schuetzen. Beispiele: Regelmaessige Backups, Redundanz, Disaster-Recovery-Plan, USV.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Trennungskontrolle ───────────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_separation",
|
||||||
|
"label": "Trennungskontrolle (Mandantenfaehigkeit)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"trennungskontrolle",
|
||||||
|
r"(?:mandantenf(?:ae|ä)higkeit|mandantentrennung|datentrennung)",
|
||||||
|
r"(?:logische|physische)\s+trennung\s+(?:der\s+)?(?:daten|systeme)",
|
||||||
|
r"(?:zweckbindung|zwecktrennung)",
|
||||||
|
],
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"hint": "Trennungskontrolle: Sicherstellen, dass Daten verschiedener Auftraggeber/Zwecke getrennt verarbeitet werden. Beispiele: Mandantenfaehige Software, getrennte Datenbanken, Berechtigungskonzept.",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── L1: Verschluesselung at Rest ─────────────────────────────────
|
||||||
|
{
|
||||||
|
"id": "tom_encryption_rest",
|
||||||
|
"label": "Verschluesselung gespeicherter Daten (at Rest)",
|
||||||
|
"level": 1, "parent": None,
|
||||||
|
"patterns": [
|
||||||
|
r"verschl(?:ue|ü)sselung[\s\S]{0,50}(?:gespeichert|ruhend|at[\s-]?rest|festplatte|datentraeger|datenbank)",
|
||||||
|
r"(?:at[\s-]?rest|ruhende\s+daten|gespeicherte\s+daten)[\s\S]{0,50}verschl(?:ue|ü)ssel",
|
||||||
|
r"(?:festplatten|datentraeger|datenbank|disk)[\s-]?verschl(?:ue|ü)sselung",
|
||||||
|
],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"hint": "Art. 32(1)(a) DSGVO: Verschluesselung ist explizit als Sicherheitsmassnahme benannt. Ohne Verschluesselung at Rest ist der Schutz bei physischem Verlust von Datentraegern nicht gewaehrleistet.",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Vendor Assessment Cross-Check — checks consistency BETWEEN documents.
|
||||||
|
|
||||||
|
Analogous to banner_cookie_cross_check.py but for vendor contracts:
|
||||||
|
- AVV references SCC → is SCC document present?
|
||||||
|
- AVV references TOM annex → is TOM document uploaded?
|
||||||
|
- AVV mentions sub-processors → does sub-processor list match?
|
||||||
|
- AVV mentions third-country transfer → is transfer mechanism documented?
|
||||||
|
|
||||||
|
Returns CheckItem-compatible dicts (same format as cross_check_vendors_vs_dsi).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cross_check_documents(
|
||||||
|
doc_texts: dict[str, str],
|
||||||
|
vendor_name: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Cross-check consistency between uploaded vendor documents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc_texts: Mapping of doc_type → extracted text
|
||||||
|
vendor_name: The vendor being assessed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CheckItem-compatible finding dicts.
|
||||||
|
"""
|
||||||
|
findings: list[dict] = []
|
||||||
|
|
||||||
|
avv_text = _get_text(doc_texts, ["avv", "auftragsverarbeitung", "dpa"])
|
||||||
|
scc_text = _get_text(doc_texts, ["scc", "standardvertragsklauseln"])
|
||||||
|
tom_text = _get_text(doc_texts, ["tom_annex", "tom_anlage", "tom"])
|
||||||
|
sub_text = _get_text(doc_texts, ["sub_processor_list", "sub_processor", "unterauftragnehmer"])
|
||||||
|
|
||||||
|
if not avv_text:
|
||||||
|
# No AVV → biggest gap
|
||||||
|
findings.append(_finding(
|
||||||
|
"cross-no-avv",
|
||||||
|
f"Kein AVV fuer '{vendor_name}' vorhanden",
|
||||||
|
"CRITICAL",
|
||||||
|
f"Ohne Auftragsverarbeitungsvertrag (AVV) nach Art. 28 DSGVO ist "
|
||||||
|
f"jede Verarbeitung personenbezogener Daten durch '{vendor_name}' "
|
||||||
|
f"rechtswidrig. Dies ist der schwerwiegendste Mangel.",
|
||||||
|
))
|
||||||
|
return findings
|
||||||
|
|
||||||
|
avv_lower = avv_text.lower()
|
||||||
|
|
||||||
|
# ── AVV references TOM but no TOM document ──────────────────────
|
||||||
|
tom_referenced = bool(re.search(
|
||||||
|
r"(?:tom|technische[\s\S]{0,10}organisatorische|art(?:ikel)?\s*\.?\s*32|"
|
||||||
|
r"anlage[\s\S]{0,20}(?:tom|sicherheit|massnahmen))",
|
||||||
|
avv_lower,
|
||||||
|
))
|
||||||
|
if tom_referenced and not tom_text:
|
||||||
|
findings.append(_finding(
|
||||||
|
"cross-tom-missing",
|
||||||
|
"AVV verweist auf TOM-Anlage — Dokument fehlt",
|
||||||
|
"HIGH",
|
||||||
|
"Der AVV verweist auf technische und organisatorische Massnahmen "
|
||||||
|
"(Art. 32 DSGVO), aber es wurde keine TOM-Anlage hochgeladen. "
|
||||||
|
"Ohne TOM-Nachweis kann die Angemessenheit der Sicherheitsmassnahmen "
|
||||||
|
"nicht beurteilt werden.",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── AVV mentions third-country but no SCC ───────────────────────
|
||||||
|
third_country = bool(re.search(
|
||||||
|
r"(?:drittland|third\s+country|usa|united\s+states|nicht[\s-]?(?:eu|ewr)|"
|
||||||
|
r"ausserhalb\s+(?:des\s+)?(?:eu|ewr|europaeischen))",
|
||||||
|
avv_lower,
|
||||||
|
))
|
||||||
|
scc_referenced = bool(re.search(
|
||||||
|
r"(?:standardvertragsklausel|scc|standard\s+contractual|2021/914)",
|
||||||
|
avv_lower,
|
||||||
|
))
|
||||||
|
if third_country and not scc_text and not scc_referenced:
|
||||||
|
findings.append(_finding(
|
||||||
|
"cross-scc-missing",
|
||||||
|
"AVV erwaehnt Drittlandtransfer — keine SCC vorhanden",
|
||||||
|
"CRITICAL",
|
||||||
|
"Der AVV erwaehnt Datenverarbeitung in einem Drittland, "
|
||||||
|
"aber es wurden keine Standardvertragsklauseln (SCC) hochgeladen "
|
||||||
|
"und der AVV verweist auch nicht auf ein Data Privacy Framework. "
|
||||||
|
"Ohne Transfermechanismus ist die Uebermittlung rechtswidrig "
|
||||||
|
"(Art. 44-49 DSGVO, EuGH Schrems II C-311/18).",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── AVV mentions sub-processors but no list ─────────────────────
|
||||||
|
sub_mentioned = bool(re.search(
|
||||||
|
r"(?:unterauftragnehmer|sub[\s-]?processor|unterauftragsverarbeiter|"
|
||||||
|
r"weitere[rn]?\s+auftragsverarbeiter)",
|
||||||
|
avv_lower,
|
||||||
|
))
|
||||||
|
if sub_mentioned and not sub_text:
|
||||||
|
findings.append(_finding(
|
||||||
|
"cross-sub-list-missing",
|
||||||
|
"AVV erwaehnt Unterauftragnehmer — Liste fehlt",
|
||||||
|
"HIGH",
|
||||||
|
"Der AVV regelt den Einsatz von Unterauftragsverarbeitern, "
|
||||||
|
"aber es wurde keine Sub-Processor-Liste hochgeladen. "
|
||||||
|
"Art. 28(2) DSGVO verlangt, dass der Verantwortliche "
|
||||||
|
"ueber alle Unterauftragnehmer informiert ist.",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── SCC present but no TIA referenced ───────────────────────────
|
||||||
|
if scc_text:
|
||||||
|
scc_lower = scc_text.lower()
|
||||||
|
tia_present = bool(re.search(
|
||||||
|
r"(?:transfer\s+impact|uebermittlungs[\s-]?risiko|"
|
||||||
|
r"klausel\s+14|clause\s+14|risikobewertung[\s\S]{0,50}drittland)",
|
||||||
|
scc_lower,
|
||||||
|
))
|
||||||
|
if not tia_present:
|
||||||
|
findings.append(_finding(
|
||||||
|
"cross-scc-no-tia",
|
||||||
|
"SCC vorhanden aber kein Transfer Impact Assessment (TIA)",
|
||||||
|
"HIGH",
|
||||||
|
"Standardvertragsklauseln wurden hochgeladen, aber ein "
|
||||||
|
"Transfer Impact Assessment (TIA) fehlt oder wird nicht "
|
||||||
|
"referenziert. Klausel 14 der SCC 2021 verlangt eine "
|
||||||
|
"Bewertung der Rechtslage im Zielland (EuGH Schrems II).",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── TOM present but incomplete ──────────────────────────────────
|
||||||
|
if tom_text:
|
||||||
|
tom_lower = tom_text.lower()
|
||||||
|
encryption_mentioned = bool(re.search(
|
||||||
|
r"(?:verschl(?:ue|ü)sselung|encryption|aes|tls|ssl)", tom_lower,
|
||||||
|
))
|
||||||
|
if not encryption_mentioned:
|
||||||
|
findings.append(_finding(
|
||||||
|
"cross-tom-no-encryption",
|
||||||
|
"TOM-Anlage erwaehnt keine Verschluesselung",
|
||||||
|
"HIGH",
|
||||||
|
"Die TOM-Anlage beschreibt keine Verschluesselungsmassnahmen. "
|
||||||
|
"Art. 32(1)(a) DSGVO nennt Verschluesselung explizit als "
|
||||||
|
"Sicherheitsmassnahme. Fehlende Verschluesselung ist einer "
|
||||||
|
"der haeufigsten Beanstandungspunkte bei Audits.",
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.info("Cross-check: %d findings for vendor '%s'",
|
||||||
|
len(findings), vendor_name)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_text(doc_texts: dict[str, str], keys: list[str]) -> str:
|
||||||
|
"""Get text for the first matching doc_type key."""
|
||||||
|
for k in keys:
|
||||||
|
if k in doc_texts:
|
||||||
|
return doc_texts[k]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(id: str, label: str, severity: str, hint: str) -> dict:
|
||||||
|
"""Create a CheckItem-compatible finding dict."""
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"label": label,
|
||||||
|
"passed": False,
|
||||||
|
"severity": severity,
|
||||||
|
"level": 1,
|
||||||
|
"parent": None,
|
||||||
|
"skipped": False,
|
||||||
|
"matched_text": "",
|
||||||
|
"hint": hint,
|
||||||
|
"source": "contract_cross_check",
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""
|
||||||
|
Vendor Assessment Pruefprotokoll — HTML report builder.
|
||||||
|
|
||||||
|
Generates a professional assessment report styled like a real DSB
|
||||||
|
Pruefprotokoll for vendor contract analysis (Art. 28 DSGVO).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def build_pruefprotokoll(result: dict) -> str:
|
||||||
|
"""Build HTML Pruefprotokoll from assessment result."""
|
||||||
|
vendor = result.get("vendor_name", "Unbekannt")
|
||||||
|
docs = result.get("documents", [])
|
||||||
|
findings = result.get("findings", [])
|
||||||
|
cross = result.get("cross_check_findings", [])
|
||||||
|
cat_scores = result.get("category_scores", {})
|
||||||
|
overall = result.get("overall_score", 0)
|
||||||
|
checked_at = result.get("checked_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
|
||||||
|
verdict = _verdict(overall)
|
||||||
|
now_str = _format_date(checked_at)
|
||||||
|
protocol_nr = f"VP-{datetime.now().strftime('%Y')}-{abs(hash(vendor)) % 10000:04d}"
|
||||||
|
|
||||||
|
html = [_style(), '<div class="report">']
|
||||||
|
|
||||||
|
# ── 1. Kopfdaten ────────────────────────────────────────────────
|
||||||
|
html.append(f'''
|
||||||
|
<div class="header">
|
||||||
|
<h1>Pruefprotokoll</h1>
|
||||||
|
<h2>Auftragsverarbeitung gem. Art. 28 DSGVO</h2>
|
||||||
|
</div>
|
||||||
|
<table class="meta">
|
||||||
|
<tr><td class="label">Protokoll-Nr.</td><td>{protocol_nr}</td></tr>
|
||||||
|
<tr><td class="label">Pruefungsdatum</td><td>{now_str}</td></tr>
|
||||||
|
<tr><td class="label">Auftragsverarbeiter</td><td><strong>{vendor}</strong></td></tr>
|
||||||
|
<tr><td class="label">Pruefungsumfang</td><td>{len(docs)} Dokument(e)</td></tr>
|
||||||
|
<tr><td class="label">Pruefer</td><td>Automatisierte Pruefung (BreakPilot Compliance SDK)</td></tr>
|
||||||
|
<tr><td class="label">Freigabe</td><td><em>Ausstehend</em></td></tr>
|
||||||
|
</table>''')
|
||||||
|
|
||||||
|
# ── 2. Zusammenfassung ──────────────────────────────────────────
|
||||||
|
critical_count = sum(1 for f in findings if _get(f, "severity") == "CRITICAL")
|
||||||
|
critical_count += sum(1 for f in cross if f.get("severity") == "CRITICAL")
|
||||||
|
total_findings = len(findings) + len(cross)
|
||||||
|
|
||||||
|
html.append(f'''
|
||||||
|
<div class="score-box {verdict["class"]}">
|
||||||
|
<div class="score-value">{overall}%</div>
|
||||||
|
<div class="score-label">{verdict["label"]}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<div class="stat"><span class="stat-value">{len(docs)}</span><span class="stat-label">Dokumente</span></div>
|
||||||
|
<div class="stat"><span class="stat-value">{total_findings}</span><span class="stat-label">Findings</span></div>
|
||||||
|
<div class="stat"><span class="stat-value red">{critical_count}</span><span class="stat-label">Kritisch</span></div>
|
||||||
|
</div>''')
|
||||||
|
|
||||||
|
# ── Kategorie-Scores ────────────────────────────────────────────
|
||||||
|
if cat_scores:
|
||||||
|
html.append('<h3>Kategorie-Uebersicht</h3><table class="cat-table">')
|
||||||
|
html.append('<tr><th>Kategorie</th><th>Score</th><th>Status</th></tr>')
|
||||||
|
for cat, score in sorted(cat_scores.items(), key=lambda x: x[1]):
|
||||||
|
status = _cat_status(score)
|
||||||
|
html.append(f'''<tr>
|
||||||
|
<td>{_cat_label(cat)}</td>
|
||||||
|
<td>{_bar(score)}</td>
|
||||||
|
<td><span class="badge {status["class"]}">{status["label"]}</span></td>
|
||||||
|
</tr>''')
|
||||||
|
html.append('</table>')
|
||||||
|
|
||||||
|
# ── 3. Gepruefte Dokumente ──────────────────────────────────────
|
||||||
|
html.append('<h3>Gepruefte Dokumente</h3>')
|
||||||
|
for i, doc in enumerate(docs):
|
||||||
|
_render_document(html, doc, i + 1)
|
||||||
|
|
||||||
|
# ── 4. Cross-Check Findings ─────────────────────────────────────
|
||||||
|
if cross:
|
||||||
|
html.append('<h3>Dokumenten-Cross-Check</h3>')
|
||||||
|
for f in cross:
|
||||||
|
sev = f.get("severity", "MEDIUM")
|
||||||
|
html.append(f'''<div class="finding {sev.lower()}">
|
||||||
|
<span class="sev-badge {sev.lower()}">{sev}</span>
|
||||||
|
<strong>{f.get("label", "")}</strong>
|
||||||
|
<p class="hint">{f.get("hint", "")}</p>
|
||||||
|
</div>''')
|
||||||
|
|
||||||
|
# ── 5. Findings ─────────────────────────────────────────────────
|
||||||
|
if findings:
|
||||||
|
html.append('<h3>Findings (sortiert nach Schweregrad)</h3>')
|
||||||
|
sorted_findings = sorted(findings,
|
||||||
|
key=lambda f: {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}.get(
|
||||||
|
_get(f, "severity"), 4))
|
||||||
|
for f in sorted_findings:
|
||||||
|
sev = _get(f, "severity")
|
||||||
|
html.append(f'''<div class="finding {sev.lower()}">
|
||||||
|
<span class="sev-badge {sev.lower()}">{sev}</span>
|
||||||
|
<strong>{_get(f, "title")}</strong>
|
||||||
|
<div class="finding-meta">{_get(f, "category")} | {_get(f, "document_label")}</div>
|
||||||
|
<p class="hint">{_get(f, "description")}</p>
|
||||||
|
</div>''')
|
||||||
|
|
||||||
|
# ── 6. Freigabe-Block ───────────────────────────────────────────
|
||||||
|
html.append(f'''
|
||||||
|
<div class="approval-block">
|
||||||
|
<h3>Freigabe</h3>
|
||||||
|
<div class="approval-options">
|
||||||
|
<label><input type="checkbox" disabled> Pruefprotokoll wird freigegeben</label>
|
||||||
|
<label><input type="checkbox" disabled> Pruefprotokoll wird mit Auflagen freigegeben</label>
|
||||||
|
<label><input type="checkbox" disabled> Pruefprotokoll wird abgelehnt</label>
|
||||||
|
</div>
|
||||||
|
<div class="signature">
|
||||||
|
<div>Datum: _______________</div>
|
||||||
|
<div>Unterschrift DSB: _______________</div>
|
||||||
|
</div>
|
||||||
|
</div>''')
|
||||||
|
|
||||||
|
html.append('</div>')
|
||||||
|
return "\n".join(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document(html: list[str], doc: dict, num: int):
|
||||||
|
"""Render a single document section with L1/L2 checks."""
|
||||||
|
label = doc.get("label", "Dokument")
|
||||||
|
dtype = doc.get("doc_type", "").upper()
|
||||||
|
comp = doc.get("completeness_pct", 0)
|
||||||
|
corr = doc.get("correctness_pct", 0)
|
||||||
|
error = doc.get("error", "")
|
||||||
|
checks = doc.get("checks", [])
|
||||||
|
|
||||||
|
html.append(f'''<div class="doc-section">
|
||||||
|
<h4>{num}. {label} <span class="doc-type">{dtype}</span></h4>''')
|
||||||
|
|
||||||
|
if error:
|
||||||
|
html.append(f'<div class="error">{error}</div></div>')
|
||||||
|
return
|
||||||
|
|
||||||
|
html.append(f'''
|
||||||
|
<div class="doc-scores">
|
||||||
|
<span>Vollstaendigkeit: {_bar(comp)}</span>
|
||||||
|
<span>Korrektheit: {_bar(corr)}</span>
|
||||||
|
</div>''')
|
||||||
|
|
||||||
|
# L1/L2 hierarchy
|
||||||
|
l1_checks = [c for c in checks if c.get("level") == 1]
|
||||||
|
l2_by_parent = {}
|
||||||
|
for c in checks:
|
||||||
|
if c.get("level") == 2 and c.get("parent"):
|
||||||
|
l2_by_parent.setdefault(c["parent"], []).append(c)
|
||||||
|
|
||||||
|
if l1_checks:
|
||||||
|
html.append('<table class="check-table">')
|
||||||
|
for c in l1_checks:
|
||||||
|
passed = c.get("passed", False)
|
||||||
|
skipped = c.get("skipped", False)
|
||||||
|
icon = _icon(passed, skipped)
|
||||||
|
sev = c.get("severity", "")
|
||||||
|
html.append(f'''<tr class="l1 {'pass' if passed else 'fail' if not skipped else 'skip'}">
|
||||||
|
<td class="icon">{icon}</td>
|
||||||
|
<td>{c.get("label", "")}</td>
|
||||||
|
<td><span class="sev-badge {sev.lower()}">{sev}</span></td>
|
||||||
|
</tr>''')
|
||||||
|
|
||||||
|
if not passed and not skipped and c.get("hint"):
|
||||||
|
html.append(f'<tr><td></td><td colspan="2" class="hint-cell">{c["hint"]}</td></tr>')
|
||||||
|
|
||||||
|
for l2 in l2_by_parent.get(c.get("id", ""), []):
|
||||||
|
l2_passed = l2.get("passed", False)
|
||||||
|
l2_icon = _icon(l2_passed, l2.get("skipped", False))
|
||||||
|
html.append(f'''<tr class="l2 {'pass' if l2_passed else 'fail'}">
|
||||||
|
<td class="icon">{l2_icon}</td>
|
||||||
|
<td style="padding-left:24px">{l2.get("label", "")}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>''')
|
||||||
|
|
||||||
|
html.append('</table>')
|
||||||
|
|
||||||
|
html.append('</div>')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get(obj, key, default=""):
|
||||||
|
"""Get from dict or Pydantic model."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict(score: int) -> dict:
|
||||||
|
if score >= 80:
|
||||||
|
return {"label": "Bestanden", "class": "green"}
|
||||||
|
if score >= 50:
|
||||||
|
return {"label": "Bedingt bestanden — Nachbesserung erforderlich", "class": "yellow"}
|
||||||
|
return {"label": "Nicht bestanden", "class": "red"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cat_status(score: int) -> dict:
|
||||||
|
if score >= 80:
|
||||||
|
return {"label": "Bestanden", "class": "green"}
|
||||||
|
if score >= 50:
|
||||||
|
return {"label": "Teilweise", "class": "yellow"}
|
||||||
|
return {"label": "Mangelhaft", "class": "red"}
|
||||||
|
|
||||||
|
|
||||||
|
_CAT_LABELS = {
|
||||||
|
"INSTRUCTION": "Weisungsgebundenheit (Art. 28(3)(a))",
|
||||||
|
"CONFIDENTIALITY": "Vertraulichkeit (Art. 28(3)(b))",
|
||||||
|
"TOM": "Technische/Org. Massnahmen (Art. 32)",
|
||||||
|
"SUBPROCESSOR": "Unterauftragsverarbeitung (Art. 28(3)(d))",
|
||||||
|
"DATA_SUBJECT_RIGHTS": "Betroffenenrechte (Art. 28(3)(e))",
|
||||||
|
"DELETION": "Loeschung/Rueckgabe (Art. 28(3)(g))",
|
||||||
|
"AUDIT_RIGHTS": "Audit-/Inspektionsrechte (Art. 28(3)(h))",
|
||||||
|
"INCIDENT": "Datenschutzverletzungen (Art. 33)",
|
||||||
|
"TRANSFER": "Drittlandtransfer (Art. 44-49)",
|
||||||
|
"LIABILITY": "Haftung (Art. 82)",
|
||||||
|
"AVV_CONTENT": "AVV Inhalt (Art. 28(3))",
|
||||||
|
"GENERAL": "Allgemein",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cat_label(cat: str) -> str:
|
||||||
|
return _CAT_LABELS.get(cat, cat)
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(pct: int) -> str:
|
||||||
|
color = "#22c55e" if pct >= 80 else "#eab308" if pct >= 50 else "#ef4444"
|
||||||
|
return (
|
||||||
|
f'<div style="display:inline-block;width:100px;height:8px;background:#e5e7eb;'
|
||||||
|
f'border-radius:4px;overflow:hidden;vertical-align:middle;margin-right:6px">'
|
||||||
|
f'<div style="width:{pct}%;height:100%;background:{color};border-radius:4px"></div>'
|
||||||
|
f'</div><span style="font-weight:600;color:{color}">{pct}%</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _icon(passed: bool, skipped: bool = False) -> str:
|
||||||
|
if skipped:
|
||||||
|
return '<span style="color:#d1d5db">—</span>'
|
||||||
|
if passed:
|
||||||
|
return '<span style="color:#22c55e;font-weight:bold">✓</span>'
|
||||||
|
return '<span style="color:#ef4444;font-weight:bold">✗</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(iso: str) -> str:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%d.%m.%Y %H:%M")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _style() -> str:
|
||||||
|
return '''<style>
|
||||||
|
.report { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 0 auto; color: #1f2937; }
|
||||||
|
.header { text-align: center; border-bottom: 2px solid #1f2937; padding-bottom: 12px; margin-bottom: 20px; }
|
||||||
|
.header h1 { margin: 0; font-size: 24px; }
|
||||||
|
.header h2 { margin: 4px 0 0; font-size: 14px; color: #6b7280; font-weight: normal; }
|
||||||
|
.meta { width: 100%; margin-bottom: 24px; border-collapse: collapse; }
|
||||||
|
.meta td { padding: 4px 12px; font-size: 13px; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
.meta .label { font-weight: 600; color: #6b7280; width: 180px; }
|
||||||
|
.score-box { text-align: center; padding: 20px; border-radius: 12px; margin: 16px 0; }
|
||||||
|
.score-box.green { background: #f0fdf4; border: 2px solid #86efac; }
|
||||||
|
.score-box.yellow { background: #fefce8; border: 2px solid #fde047; }
|
||||||
|
.score-box.red { background: #fef2f2; border: 2px solid #fca5a5; }
|
||||||
|
.score-value { font-size: 48px; font-weight: 800; }
|
||||||
|
.green .score-value { color: #16a34a; }
|
||||||
|
.yellow .score-value { color: #ca8a04; }
|
||||||
|
.red .score-value { color: #dc2626; }
|
||||||
|
.score-label { font-size: 14px; color: #6b7280; margin-top: 4px; }
|
||||||
|
.summary-row { display: flex; gap: 16px; margin: 16px 0 24px; }
|
||||||
|
.stat { flex: 1; text-align: center; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
|
||||||
|
.stat-value { display: block; font-size: 28px; font-weight: 700; color: #1f2937; }
|
||||||
|
.stat-value.red { color: #dc2626; }
|
||||||
|
.stat-label { display: block; font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
h3 { font-size: 16px; margin: 28px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.cat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.cat-table th { text-align: left; padding: 6px 8px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #6b7280; }
|
||||||
|
.cat-table td { padding: 8px; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge.green { background: #dcfce7; color: #16a34a; }
|
||||||
|
.badge.yellow { background: #fef9c3; color: #ca8a04; }
|
||||||
|
.badge.red { background: #fee2e2; color: #dc2626; }
|
||||||
|
.doc-section { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin: 12px 0; }
|
||||||
|
.doc-section h4 { margin: 0 0 8px; font-size: 14px; }
|
||||||
|
.doc-type { background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||||
|
.doc-scores { display: flex; gap: 24px; margin-bottom: 12px; font-size: 13px; }
|
||||||
|
.error { color: #dc2626; font-size: 13px; }
|
||||||
|
.check-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.check-table td { padding: 4px 6px; }
|
||||||
|
.check-table .icon { width: 20px; text-align: center; }
|
||||||
|
.l1 { font-weight: 500; }
|
||||||
|
.l1.fail td { background: #fef2f2; }
|
||||||
|
.l2 { color: #6b7280; }
|
||||||
|
.hint-cell { font-size: 11px; color: #dc2626; padding: 2px 6px 8px 26px !important; background: #fef2f2; border-left: 3px solid #fca5a5; }
|
||||||
|
.sev-badge { padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; }
|
||||||
|
.sev-badge.critical { background: #fee2e2; color: #dc2626; }
|
||||||
|
.sev-badge.high { background: #ffedd5; color: #ea580c; }
|
||||||
|
.sev-badge.medium { background: #fef9c3; color: #ca8a04; }
|
||||||
|
.sev-badge.low { background: #dcfce7; color: #16a34a; }
|
||||||
|
.finding { border-left: 4px solid; padding: 10px 14px; margin: 8px 0; border-radius: 0 8px 8px 0; background: #fff; }
|
||||||
|
.finding.critical { border-color: #dc2626; background: #fef2f2; }
|
||||||
|
.finding.high { border-color: #ea580c; background: #fff7ed; }
|
||||||
|
.finding.medium { border-color: #ca8a04; background: #fefce8; }
|
||||||
|
.finding.low { border-color: #16a34a; background: #f0fdf4; }
|
||||||
|
.finding-meta { font-size: 11px; color: #9ca3af; margin: 2px 0; }
|
||||||
|
.hint { font-size: 12px; color: #4b5563; margin: 6px 0 0; line-height: 1.5; }
|
||||||
|
.approval-block { margin-top: 32px; padding: 20px; border: 2px dashed #d1d5db; border-radius: 8px; }
|
||||||
|
.approval-options label { display: block; margin: 8px 0; font-size: 13px; }
|
||||||
|
.signature { display: flex; gap: 40px; margin-top: 24px; font-size: 13px; color: #6b7280; }
|
||||||
|
</style>'''
|
||||||
@@ -50,6 +50,7 @@ from compliance.api.agent_recurring_routes import router as agent_recurring_rout
|
|||||||
from compliance.api.agent_compare_routes import router as agent_compare_router
|
from compliance.api.agent_compare_routes import router as agent_compare_router
|
||||||
from compliance.api.agent_doc_check_routes import router as agent_doc_check_router
|
from compliance.api.agent_doc_check_routes import router as agent_doc_check_router
|
||||||
from compliance.api.agent_compliance_check_routes import router as agent_compliance_check_router
|
from compliance.api.agent_compliance_check_routes import router as agent_compliance_check_router
|
||||||
|
from compliance.api.vendor_assessment_routes import router as vendor_assessment_router
|
||||||
|
|
||||||
# Middleware
|
# Middleware
|
||||||
from middleware import (
|
from middleware import (
|
||||||
@@ -155,6 +156,9 @@ app.include_router(agent_compare_router, prefix="/api")
|
|||||||
app.include_router(agent_doc_check_router, prefix="/api")
|
app.include_router(agent_doc_check_router, prefix="/api")
|
||||||
app.include_router(agent_compliance_check_router, prefix="/api")
|
app.include_router(agent_compliance_check_router, prefix="/api")
|
||||||
|
|
||||||
|
# Vendor Contract Assessment
|
||||||
|
app.include_router(vendor_assessment_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
Reference in New Issue
Block a user