feat(vendor-assessment): AVV/SCC/TOM/Sub-Processor checklists + assessment service

Phase 1-3 of the Vendor Contract Assessment:

Backend checklists (Doc-Check L1/L2 engine compatible):
- avv_checks.py: 28 checks (11 L1 + 17 L2) for Art. 28(3) DSGVO
- scc_checks.py: 7 checks for EU SCC 2021 (modules, annexes, TIA)
- tom_annex_checks.py: 12 checks for Art. 32 (8 control objectives)
- sub_processor_checks.py: 7 checks for sub-processor list completeness

Assessment service:
- POST /vendor-compliance/assessments — async contract analysis
- GET /vendor-compliance/assessments/{id} — poll status
- Cross-check engine: detects missing SCC when AVV mentions third-country,
  missing TOM annex, missing sub-processor list

All checklists registered in runner.py CHECKLIST_MAP (27 doc_types total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-12 23:14:54 +02:00
parent c867478791
commit 0326d5baab
8 changed files with 1359 additions and 0 deletions
@@ -0,0 +1,408 @@
"""
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,
)
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] = []
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(),
)
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 .dsfa_checks import DSFA_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__)
@@ -37,6 +41,17 @@ _CHECKLIST_MAP = {
"joint_controller": (JOINT_CONTROLLER_CHECKLIST, "Art. 26 DSGVO"),
"dsfa": (DSFA_CHECKLIST, "Art. 35 DSGVO"),
"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",
}
+4
View File
@@ -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_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.vendor_assessment_routes import router as vendor_assessment_router
# Middleware
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_compliance_check_router, prefix="/api")
# Vendor Contract Assessment
app.include_router(vendor_assessment_router, prefix="/api")
if __name__ == "__main__":
import uvicorn