VVT: Master library tables (7 catalogs), 500+ seed entries, process templates with instantiation, library API endpoints + 18 tests. Loeschfristen: Baseline catalog, compliance checks, profiling engine, HTML document generator, MkDocs documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
// =============================================================================
|
|
// Loeschfristen Module - Compliance Check Engine
|
|
// Prueft Policies auf Vollstaendigkeit, Konsistenz und DSGVO-Konformitaet
|
|
// =============================================================================
|
|
|
|
import {
|
|
LoeschfristPolicy,
|
|
PolicyStatus,
|
|
RetentionDriverType,
|
|
isPolicyOverdue,
|
|
getActiveLegalHolds,
|
|
RETENTION_DRIVER_META,
|
|
} from './loeschfristen-types'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export type ComplianceIssueType =
|
|
| 'MISSING_TRIGGER'
|
|
| 'MISSING_LEGAL_BASIS'
|
|
| 'OVERDUE_REVIEW'
|
|
| 'NO_RESPONSIBLE'
|
|
| 'LEGAL_HOLD_CONFLICT'
|
|
| 'STALE_DRAFT'
|
|
| 'UNCOVERED_VVT_CATEGORY'
|
|
| 'MISSING_DELETION_METHOD'
|
|
| 'MISSING_STORAGE_LOCATIONS'
|
|
| 'EXCESSIVE_RETENTION'
|
|
| 'MISSING_DATA_CATEGORIES'
|
|
|
|
export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
|
|
|
export interface ComplianceIssue {
|
|
id: string
|
|
policyId: string
|
|
policyName: string
|
|
type: ComplianceIssueType
|
|
severity: ComplianceIssueSeverity
|
|
title: string
|
|
description: string
|
|
recommendation: string
|
|
}
|
|
|
|
export interface ComplianceCheckResult {
|
|
issues: ComplianceIssue[]
|
|
score: number // 0-100
|
|
stats: {
|
|
total: number
|
|
passed: number
|
|
failed: number
|
|
bySeverity: Record<ComplianceIssueSeverity, number>
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// HELPERS
|
|
// =============================================================================
|
|
|
|
let issueCounter = 0
|
|
|
|
function createIssueId(): string {
|
|
issueCounter++
|
|
return `CI-${Date.now()}-${String(issueCounter).padStart(4, '0')}`
|
|
}
|
|
|
|
function createIssue(
|
|
policy: LoeschfristPolicy,
|
|
type: ComplianceIssueType,
|
|
severity: ComplianceIssueSeverity,
|
|
title: string,
|
|
description: string,
|
|
recommendation: string
|
|
): ComplianceIssue {
|
|
return {
|
|
id: createIssueId(),
|
|
policyId: policy.policyId,
|
|
policyName: policy.dataObjectName || policy.policyId,
|
|
type,
|
|
severity,
|
|
title,
|
|
description,
|
|
recommendation,
|
|
}
|
|
}
|
|
|
|
function daysBetween(dateStr: string, now: Date): number {
|
|
const date = new Date(dateStr)
|
|
const diffMs = now.getTime() - date.getTime()
|
|
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
}
|
|
|
|
// =============================================================================
|
|
// INDIVIDUAL CHECKS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check 1: MISSING_TRIGGER (HIGH)
|
|
* Policy has no deletionTrigger set, or trigger is PURPOSE_END but no startEvent defined.
|
|
*/
|
|
function checkMissingTrigger(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (!policy.deletionTrigger) {
|
|
return createIssue(
|
|
policy,
|
|
'MISSING_TRIGGER',
|
|
'HIGH',
|
|
'Kein Loeschtrigger definiert',
|
|
`Die Policy "${policy.dataObjectName}" hat keinen Loeschtrigger gesetzt. Ohne Trigger ist unklar, wann die Daten geloescht werden.`,
|
|
'Definieren Sie einen Loeschtrigger (Zweckende, Aufbewahrungspflicht oder Legal Hold) fuer diese Policy.'
|
|
)
|
|
}
|
|
|
|
if (policy.deletionTrigger === 'PURPOSE_END' && !policy.startEvent.trim()) {
|
|
return createIssue(
|
|
policy,
|
|
'MISSING_TRIGGER',
|
|
'HIGH',
|
|
'Zweckende ohne Startereignis',
|
|
`Die Policy "${policy.dataObjectName}" nutzt "Zweckende" als Trigger, hat aber kein Startereignis definiert. Ohne Startereignis laesst sich der Loeschzeitpunkt nicht berechnen.`,
|
|
'Definieren Sie ein konkretes Startereignis (z.B. "Vertragsende", "Abmeldung", "Projektabschluss").'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 2: MISSING_LEGAL_BASIS (HIGH)
|
|
* Policy with RETENTION_DRIVER trigger but no retentionDriver set.
|
|
*/
|
|
function checkMissingLegalBasis(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (policy.deletionTrigger === 'RETENTION_DRIVER' && !policy.retentionDriver) {
|
|
return createIssue(
|
|
policy,
|
|
'MISSING_LEGAL_BASIS',
|
|
'HIGH',
|
|
'Aufbewahrungspflicht ohne Rechtsgrundlage',
|
|
`Die Policy "${policy.dataObjectName}" hat "Aufbewahrungspflicht" als Trigger, aber keinen konkreten Aufbewahrungstreiber (z.B. AO 147, HGB 257) zugeordnet.`,
|
|
'Waehlen Sie den passenden gesetzlichen Aufbewahrungstreiber aus oder wechseln Sie den Trigger-Typ.'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 3: OVERDUE_REVIEW (MEDIUM)
|
|
* Policy where nextReviewDate is in the past.
|
|
*/
|
|
function checkOverdueReview(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (isPolicyOverdue(policy)) {
|
|
const overdueDays = daysBetween(policy.nextReviewDate, new Date())
|
|
return createIssue(
|
|
policy,
|
|
'OVERDUE_REVIEW',
|
|
'MEDIUM',
|
|
'Ueberfaellige Pruefung',
|
|
`Die Policy "${policy.dataObjectName}" haette am ${new Date(policy.nextReviewDate).toLocaleDateString('de-DE')} geprueft werden muessen. Die Pruefung ist ${overdueDays} Tag(e) ueberfaellig.`,
|
|
'Fuehren Sie umgehend eine Pruefung dieser Policy durch und aktualisieren Sie das naechste Pruefungsdatum.'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 4: NO_RESPONSIBLE (MEDIUM)
|
|
* Policy with no responsiblePerson AND no responsibleRole.
|
|
*/
|
|
function checkNoResponsible(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (!policy.responsiblePerson.trim() && !policy.responsibleRole.trim()) {
|
|
return createIssue(
|
|
policy,
|
|
'NO_RESPONSIBLE',
|
|
'MEDIUM',
|
|
'Keine verantwortliche Person/Rolle',
|
|
`Die Policy "${policy.dataObjectName}" hat weder eine verantwortliche Person noch eine verantwortliche Rolle zugewiesen. Ohne Verantwortlichkeit kann die Loeschung nicht zuverlaessig durchgefuehrt werden.`,
|
|
'Weisen Sie eine verantwortliche Person oder zumindest eine verantwortliche Rolle (z.B. "Datenschutzbeauftragter", "IT-Leitung") zu.'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 5: LEGAL_HOLD_CONFLICT (CRITICAL)
|
|
* Policy has active legal hold but deletionMethod is AUTO_DELETE.
|
|
*/
|
|
function checkLegalHoldConflict(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
const activeHolds = getActiveLegalHolds(policy)
|
|
if (activeHolds.length > 0 && policy.deletionMethod === 'AUTO_DELETE') {
|
|
const holdReasons = activeHolds.map((h) => h.reason).join(', ')
|
|
return createIssue(
|
|
policy,
|
|
'LEGAL_HOLD_CONFLICT',
|
|
'CRITICAL',
|
|
'Legal Hold mit automatischer Loeschung',
|
|
`Die Policy "${policy.dataObjectName}" hat ${activeHolds.length} aktive(n) Legal Hold(s) (${holdReasons}), aber die Loeschmethode ist auf "Automatische Loeschung" gesetzt. Dies kann zu unbeabsichtigter Vernichtung von Beweismitteln fuehren.`,
|
|
'Aendern Sie die Loeschmethode auf "Manuelle Pruefung & Loeschung" oder deaktivieren Sie die automatische Loeschung, solange der Legal Hold aktiv ist.'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 6: STALE_DRAFT (LOW)
|
|
* Policy in DRAFT status older than 90 days.
|
|
*/
|
|
function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (policy.status === 'DRAFT') {
|
|
const ageInDays = daysBetween(policy.createdAt, new Date())
|
|
if (ageInDays > 90) {
|
|
return createIssue(
|
|
policy,
|
|
'STALE_DRAFT',
|
|
'LOW',
|
|
'Veralteter Entwurf',
|
|
`Die Policy "${policy.dataObjectName}" ist seit ${ageInDays} Tagen im Entwurfsstatus. Entwuerfe, die laenger als 90 Tage nicht finalisiert werden, deuten auf unvollstaendige Dokumentation hin.`,
|
|
'Finalisieren Sie den Entwurf und setzen Sie den Status auf "Aktiv", oder archivieren Sie die Policy, falls sie nicht mehr benoetigt wird.'
|
|
)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 8: MISSING_DELETION_METHOD (MEDIUM)
|
|
* Active policy without a deletion method detail description.
|
|
*/
|
|
function checkMissingDeletionMethod(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (policy.status === 'ACTIVE' && !policy.deletionMethodDetail.trim()) {
|
|
return createIssue(
|
|
policy,
|
|
'MISSING_DELETION_METHOD',
|
|
'MEDIUM',
|
|
'Keine Loeschmethode beschrieben',
|
|
`Die aktive Policy "${policy.dataObjectName}" hat keine detaillierte Beschreibung der Loeschmethode. Fuer ein auditfaehiges Loeschkonzept muss dokumentiert sein, wie die Loeschung technisch durchgefuehrt wird.`,
|
|
'Ergaenzen Sie eine detaillierte Beschreibung der Loeschmethode (z.B. automatisches Loeschen durch Datenbank-Job, manuelle Pruefung durch Fachabteilung, kryptographische Loeschung).'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 9: MISSING_STORAGE_LOCATIONS (MEDIUM)
|
|
* Active policy without any documented storage locations.
|
|
*/
|
|
function checkMissingStorageLocations(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (policy.status === 'ACTIVE' && policy.storageLocations.length === 0) {
|
|
return createIssue(
|
|
policy,
|
|
'MISSING_STORAGE_LOCATIONS',
|
|
'MEDIUM',
|
|
'Keine Speicherorte dokumentiert',
|
|
`Die aktive Policy "${policy.dataObjectName}" hat keine Speicherorte hinterlegt. Ohne Speicherort-Dokumentation ist unklar, wo die Daten gespeichert sind und wo die Loeschung durchgefuehrt werden muss.`,
|
|
'Dokumentieren Sie mindestens einen Speicherort (z.B. Datenbank, Cloud-Speicher, E-Mail-System, Papierarchiv).'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 10: EXCESSIVE_RETENTION (HIGH)
|
|
* Retention duration exceeds 2x the legal default for the driver.
|
|
*/
|
|
function checkExcessiveRetention(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (
|
|
policy.retentionDriver &&
|
|
policy.retentionDriver !== 'CUSTOM' &&
|
|
policy.retentionDuration !== null &&
|
|
policy.retentionUnit !== null
|
|
) {
|
|
const meta = RETENTION_DRIVER_META[policy.retentionDriver]
|
|
if (meta.defaultDuration !== null && meta.defaultUnit !== null) {
|
|
// Normalize both to days for comparison
|
|
const policyDays = toDays(policy.retentionDuration, policy.retentionUnit)
|
|
const legalDays = toDays(meta.defaultDuration, meta.defaultUnit)
|
|
|
|
if (legalDays > 0 && policyDays > legalDays * 2) {
|
|
return createIssue(
|
|
policy,
|
|
'EXCESSIVE_RETENTION',
|
|
'HIGH',
|
|
'Ueberschreitung der gesetzlichen Aufbewahrungsfrist',
|
|
`Die Policy "${policy.dataObjectName}" hat eine Aufbewahrungsdauer von ${policy.retentionDuration} ${policy.retentionUnit === 'YEARS' ? 'Jahren' : policy.retentionUnit === 'MONTHS' ? 'Monaten' : 'Tagen'}, die mehr als das Doppelte der gesetzlichen Frist (${meta.defaultDuration} ${meta.defaultUnit === 'YEARS' ? 'Jahre' : meta.defaultUnit === 'MONTHS' ? 'Monate' : 'Tage'} nach ${meta.statute}) betraegt. Ueberlange Speicherung widerspricht dem Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e DSGVO).`,
|
|
'Pruefen Sie, ob die verlaengerte Aufbewahrungsdauer gerechtfertigt ist. Falls nicht, reduzieren Sie sie auf die gesetzliche Mindestfrist.'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check 11: MISSING_DATA_CATEGORIES (LOW)
|
|
* Non-draft policy without any data categories assigned.
|
|
*/
|
|
function checkMissingDataCategories(policy: LoeschfristPolicy): ComplianceIssue | null {
|
|
if (policy.status !== 'DRAFT' && policy.dataCategories.length === 0) {
|
|
return createIssue(
|
|
policy,
|
|
'MISSING_DATA_CATEGORIES',
|
|
'LOW',
|
|
'Keine Datenkategorien zugeordnet',
|
|
`Die Policy "${policy.dataObjectName}" (Status: ${policy.status}) hat keine Datenkategorien zugeordnet. Ohne Datenkategorien ist unklar, welche personenbezogenen Daten von dieser Loeschregel betroffen sind.`,
|
|
'Ordnen Sie mindestens eine Datenkategorie zu (z.B. Stammdaten, Kontaktdaten, Finanzdaten, Gesundheitsdaten).'
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Helper: convert retention duration to days for comparison.
|
|
*/
|
|
function toDays(duration: number, unit: string): number {
|
|
switch (unit) {
|
|
case 'DAYS': return duration
|
|
case 'MONTHS': return duration * 30
|
|
case 'YEARS': return duration * 365
|
|
default: return duration
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN COMPLIANCE CHECK
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Policies durch.
|
|
*
|
|
* @param policies - Alle Loeschfrist-Policies
|
|
* @param vvtDataCategories - Optionale Datenkategorien aus dem VVT (localStorage)
|
|
* @returns ComplianceCheckResult mit Issues, Score und Statistiken
|
|
*/
|
|
export function runComplianceCheck(
|
|
policies: LoeschfristPolicy[],
|
|
vvtDataCategories?: string[]
|
|
): ComplianceCheckResult {
|
|
// Reset counter for deterministic IDs within a single check run
|
|
issueCounter = 0
|
|
|
|
const issues: ComplianceIssue[] = []
|
|
|
|
// Run checks 1-6 for each policy
|
|
for (const policy of policies) {
|
|
const checks = [
|
|
checkMissingTrigger(policy),
|
|
checkMissingLegalBasis(policy),
|
|
checkOverdueReview(policy),
|
|
checkNoResponsible(policy),
|
|
checkLegalHoldConflict(policy),
|
|
checkStaleDraft(policy),
|
|
checkMissingDeletionMethod(policy),
|
|
checkMissingStorageLocations(policy),
|
|
checkExcessiveRetention(policy),
|
|
checkMissingDataCategories(policy),
|
|
]
|
|
|
|
for (const issue of checks) {
|
|
if (issue !== null) {
|
|
issues.push(issue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 7: UNCOVERED_VVT_CATEGORY (MEDIUM)
|
|
if (vvtDataCategories && vvtDataCategories.length > 0) {
|
|
const coveredCategories = new Set<string>()
|
|
for (const policy of policies) {
|
|
for (const category of policy.dataCategories) {
|
|
coveredCategories.add(category.toLowerCase().trim())
|
|
}
|
|
}
|
|
|
|
for (const vvtCategory of vvtDataCategories) {
|
|
const normalized = vvtCategory.toLowerCase().trim()
|
|
if (!coveredCategories.has(normalized)) {
|
|
issues.push({
|
|
id: createIssueId(),
|
|
policyId: '-',
|
|
policyName: '-',
|
|
type: 'UNCOVERED_VVT_CATEGORY',
|
|
severity: 'MEDIUM',
|
|
title: `Datenkategorie ohne Loeschfrist: "${vvtCategory}"`,
|
|
description: `Die Datenkategorie "${vvtCategory}" ist im Verzeichnis der Verarbeitungstaetigkeiten (VVT) erfasst, hat aber keine zugehoerige Loeschfrist-Policy. Gemaess DSGVO Art. 5 Abs. 1 lit. e muss fuer jede Datenkategorie eine Speicherbegrenzung definiert sein.`,
|
|
recommendation: `Erstellen Sie eine neue Loeschfrist-Policy fuer die Datenkategorie "${vvtCategory}" oder ordnen Sie sie einer bestehenden Policy zu.`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate score
|
|
const bySeverity: Record<ComplianceIssueSeverity, number> = {
|
|
LOW: 0,
|
|
MEDIUM: 0,
|
|
HIGH: 0,
|
|
CRITICAL: 0,
|
|
}
|
|
|
|
for (const issue of issues) {
|
|
bySeverity[issue.severity]++
|
|
}
|
|
|
|
const rawScore =
|
|
100 -
|
|
(bySeverity.CRITICAL * 15 +
|
|
bySeverity.HIGH * 10 +
|
|
bySeverity.MEDIUM * 5 +
|
|
bySeverity.LOW * 2)
|
|
|
|
const score = Math.max(0, rawScore)
|
|
|
|
// Calculate pass/fail per policy
|
|
const failedPolicyIds = new Set(
|
|
issues.filter((i) => i.policyId !== '-').map((i) => i.policyId)
|
|
)
|
|
const totalPolicies = policies.length
|
|
const failedCount = failedPolicyIds.size
|
|
const passedCount = totalPolicies - failedCount
|
|
|
|
return {
|
|
issues,
|
|
score,
|
|
stats: {
|
|
total: totalPolicies,
|
|
passed: passedCount,
|
|
failed: failedCount,
|
|
bySeverity,
|
|
},
|
|
}
|
|
}
|