From 0326d5baabf4b1576297081578b19cdfed2073b2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 12 May 2026 23:14:54 +0200 Subject: [PATCH] feat(vendor-assessment): AVV/SCC/TOM/Sub-Processor checklists + assessment service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../api/vendor_assessment_routes.py | 408 ++++++++++++++++++ .../services/doc_checks/avv_checks.py | 381 ++++++++++++++++ .../compliance/services/doc_checks/runner.py | 15 + .../services/doc_checks/scc_checks.py | 104 +++++ .../doc_checks/sub_processor_checks.py | 103 +++++ .../services/doc_checks/tom_annex_checks.py | 173 ++++++++ .../services/vendor_assessment_cross_check.py | 171 ++++++++ backend-compliance/main.py | 4 + 8 files changed, 1359 insertions(+) create mode 100644 backend-compliance/compliance/api/vendor_assessment_routes.py create mode 100644 backend-compliance/compliance/services/doc_checks/avv_checks.py create mode 100644 backend-compliance/compliance/services/doc_checks/scc_checks.py create mode 100644 backend-compliance/compliance/services/doc_checks/sub_processor_checks.py create mode 100644 backend-compliance/compliance/services/doc_checks/tom_annex_checks.py create mode 100644 backend-compliance/compliance/services/vendor_assessment_cross_check.py diff --git a/backend-compliance/compliance/api/vendor_assessment_routes.py b/backend-compliance/compliance/api/vendor_assessment_routes.py new file mode 100644 index 0000000..61c380c --- /dev/null +++ b/backend-compliance/compliance/api/vendor_assessment_routes.py @@ -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)) diff --git a/backend-compliance/compliance/services/doc_checks/avv_checks.py b/backend-compliance/compliance/services/doc_checks/avv_checks.py new file mode 100644 index 0000000..b9e2a39 --- /dev/null +++ b/backend-compliance/compliance/services/doc_checks/avv_checks.py @@ -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).", + }, +] diff --git a/backend-compliance/compliance/services/doc_checks/runner.py b/backend-compliance/compliance/services/doc_checks/runner.py index e286bf8..a067f42 100644 --- a/backend-compliance/compliance/services/doc_checks/runner.py +++ b/backend-compliance/compliance/services/doc_checks/runner.py @@ -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"), } diff --git a/backend-compliance/compliance/services/doc_checks/scc_checks.py b/backend-compliance/compliance/services/doc_checks/scc_checks.py new file mode 100644 index 0000000..611cf6d --- /dev/null +++ b/backend-compliance/compliance/services/doc_checks/scc_checks.py @@ -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.", + }, +] diff --git a/backend-compliance/compliance/services/doc_checks/sub_processor_checks.py b/backend-compliance/compliance/services/doc_checks/sub_processor_checks.py new file mode 100644 index 0000000..72fa044 --- /dev/null +++ b/backend-compliance/compliance/services/doc_checks/sub_processor_checks.py @@ -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.", + }, +] diff --git a/backend-compliance/compliance/services/doc_checks/tom_annex_checks.py b/backend-compliance/compliance/services/doc_checks/tom_annex_checks.py new file mode 100644 index 0000000..0d715e6 --- /dev/null +++ b/backend-compliance/compliance/services/doc_checks/tom_annex_checks.py @@ -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.", + }, +] diff --git a/backend-compliance/compliance/services/vendor_assessment_cross_check.py b/backend-compliance/compliance/services/vendor_assessment_cross_check.py new file mode 100644 index 0000000..ce494fc --- /dev/null +++ b/backend-compliance/compliance/services/vendor_assessment_cross_check.py @@ -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", + } diff --git a/backend-compliance/main.py b/backend-compliance/main.py index b093d8c..521d774 100644 --- a/backend-compliance/main.py +++ b/backend-compliance/main.py @@ -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