Extract data constants and document-scope logic from the monolithic engine: - compliance-scope-data.ts (133 LOC): score weights + answer multipliers - compliance-scope-triggers.ts (823 LOC): 50 hard trigger rules (data table) - compliance-scope-documents.ts (497 LOC): document scope, risk flags, gaps, actions, reasoning - compliance-scope-engine.ts (406 LOC): core class with scoring + trigger evaluation All logic files stay under the 500 LOC cap. The triggers file exceeds it as a pure declarative data table with no logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
/**
|
|
* Document-scope calculation, risk flags, gap analysis, next actions,
|
|
* and reasoning (audit trail) helpers for the ComplianceScopeEngine.
|
|
*/
|
|
import type {
|
|
ComplianceDepthLevel,
|
|
ComplianceScores,
|
|
ScopeProfilingAnswer,
|
|
TriggeredHardTrigger,
|
|
RequiredDocument,
|
|
RiskFlag,
|
|
ScopeGap,
|
|
NextAction,
|
|
ScopeReasoning,
|
|
ScopeDocumentType,
|
|
HardTriggerRule,
|
|
} from './compliance-scope-types'
|
|
import {
|
|
getDepthLevelNumeric,
|
|
DOCUMENT_SCOPE_MATRIX,
|
|
DOCUMENT_TYPE_LABELS,
|
|
DOCUMENT_SDK_STEP_MAP,
|
|
} from './compliance-scope-types'
|
|
import { HARD_TRIGGER_RULES } from './compliance-scope-triggers'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Parse employee-count bucket string to a representative number. */
|
|
export function parseEmployeeCount(value: string): number {
|
|
if (value === '1-9') return 9
|
|
if (value === '10-49') return 49
|
|
if (value === '50-249') return 249
|
|
if (value === '250-999') return 999
|
|
if (value === '1000+') return 1000
|
|
return 0
|
|
}
|
|
|
|
/** Derive level purely from composite score. */
|
|
export function getLevelFromScore(composite: number): ComplianceDepthLevel {
|
|
if (composite <= 25) return 'L1'
|
|
if (composite <= 50) return 'L2'
|
|
if (composite <= 75) return 'L3'
|
|
return 'L4'
|
|
}
|
|
|
|
/** Highest level among the given triggers. */
|
|
export function getMaxTriggerLevel(triggers: TriggeredHardTrigger[]): ComplianceDepthLevel {
|
|
if (triggers.length === 0) return 'L1'
|
|
let max: ComplianceDepthLevel = 'L1'
|
|
for (const t of triggers) {
|
|
if (getDepthLevelNumeric(t.minimumLevel) > getDepthLevelNumeric(max)) {
|
|
max = t.minimumLevel
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// normalizeDocType
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Maps UPPERCASE document-type identifiers from the hard-trigger rules
|
|
* to the lowercase ScopeDocumentType keys.
|
|
*/
|
|
export function normalizeDocType(raw: string): ScopeDocumentType | null {
|
|
const mapping: Record<string, ScopeDocumentType> = {
|
|
VVT: 'vvt',
|
|
TOM: 'tom',
|
|
DSFA: 'dsfa',
|
|
DSE: 'dsi',
|
|
AGB: 'vertragsmanagement',
|
|
AVV: 'av_vertrag',
|
|
COOKIE_BANNER: 'einwilligung',
|
|
EINWILLIGUNGEN: 'einwilligung',
|
|
TRANSFER_DOKU: 'daten_transfer',
|
|
AUDIT_CHECKLIST: 'audit_log',
|
|
VENDOR_MANAGEMENT: 'vertragsmanagement',
|
|
LOESCHKONZEPT: 'lf',
|
|
DSR_PROZESS: 'betroffenenrechte',
|
|
NOTFALLPLAN: 'notfallplan',
|
|
AI_ACT_DOKU: 'ai_act_doku',
|
|
WIDERRUFSBELEHRUNG: 'widerrufsbelehrung',
|
|
PREISANGABEN: 'preisangaben',
|
|
FERNABSATZ_INFO: 'fernabsatz_info',
|
|
STREITBEILEGUNG: 'streitbeilegung',
|
|
PRODUKTSICHERHEIT: 'produktsicherheit',
|
|
}
|
|
if (raw in DOCUMENT_SCOPE_MATRIX) return raw as ScopeDocumentType
|
|
return mapping[raw] ?? null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Document scope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getDocumentPriority(
|
|
docType: ScopeDocumentType,
|
|
isMandatoryFromTrigger: boolean,
|
|
): 'high' | 'medium' | 'low' {
|
|
if (isMandatoryFromTrigger) return 'high'
|
|
if (['VVT', 'TOM', 'DSE'].includes(docType)) return 'high'
|
|
if (['DSFA', 'AVV', 'EINWILLIGUNGEN'].includes(docType)) return 'high'
|
|
return 'medium'
|
|
}
|
|
|
|
function estimateEffort(docType: ScopeDocumentType): number {
|
|
const effortMap: Partial<Record<ScopeDocumentType, number>> = {
|
|
vvt: 8,
|
|
tom: 12,
|
|
dsfa: 16,
|
|
av_vertrag: 4,
|
|
dsi: 6,
|
|
einwilligung: 6,
|
|
lf: 10,
|
|
daten_transfer: 8,
|
|
betroffenenrechte: 8,
|
|
notfallplan: 12,
|
|
vertragsmanagement: 10,
|
|
audit_log: 8,
|
|
risikoanalyse: 6,
|
|
schulung: 4,
|
|
datenpannen: 6,
|
|
zertifizierung: 8,
|
|
datenschutzmanagement: 12,
|
|
iace_ce_assessment: 8,
|
|
widerrufsbelehrung: 3,
|
|
preisangaben: 2,
|
|
fernabsatz_info: 4,
|
|
streitbeilegung: 1,
|
|
produktsicherheit: 8,
|
|
ai_act_doku: 12,
|
|
}
|
|
return effortMap[docType] ?? 6
|
|
}
|
|
|
|
/**
|
|
* Build the full document-scope list based on compliance level and triggers.
|
|
*/
|
|
export function buildDocumentScope(
|
|
level: ComplianceDepthLevel,
|
|
triggers: TriggeredHardTrigger[],
|
|
_answers: ScopeProfilingAnswer[],
|
|
): RequiredDocument[] {
|
|
const requiredDocs: RequiredDocument[] = []
|
|
const mandatoryFromTriggers = new Set<ScopeDocumentType>()
|
|
const triggerDocOrigins = new Map<ScopeDocumentType, string[]>()
|
|
|
|
for (const trigger of triggers) {
|
|
for (const doc of trigger.mandatoryDocuments) {
|
|
const normalized = normalizeDocType(doc)
|
|
if (normalized) {
|
|
mandatoryFromTriggers.add(normalized)
|
|
if (!triggerDocOrigins.has(normalized)) triggerDocOrigins.set(normalized, [])
|
|
triggerDocOrigins.get(normalized)!.push(doc)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const docType of Object.keys(DOCUMENT_SCOPE_MATRIX) as ScopeDocumentType[]) {
|
|
const requirement = DOCUMENT_SCOPE_MATRIX[docType][level]
|
|
const isMandatoryFromTrigger = mandatoryFromTriggers.has(docType)
|
|
|
|
if (requirement === 'mandatory' || isMandatoryFromTrigger) {
|
|
const originDocs = triggerDocOrigins.get(docType) ?? []
|
|
requiredDocs.push({
|
|
documentType: docType,
|
|
label: DOCUMENT_TYPE_LABELS[docType],
|
|
requirement: 'mandatory',
|
|
priority: getDocumentPriority(docType, isMandatoryFromTrigger),
|
|
estimatedEffort: estimateEffort(docType),
|
|
sdkStepUrl: DOCUMENT_SDK_STEP_MAP[docType],
|
|
triggeredBy: isMandatoryFromTrigger
|
|
? triggers
|
|
.filter((t) => t.mandatoryDocuments.some((d) => originDocs.includes(d)))
|
|
.map((t) => t.ruleId)
|
|
: [],
|
|
})
|
|
} else if (requirement === 'recommended') {
|
|
requiredDocs.push({
|
|
documentType: docType,
|
|
label: DOCUMENT_TYPE_LABELS[docType],
|
|
requirement: 'recommended',
|
|
priority: 'medium',
|
|
estimatedEffort: estimateEffort(docType),
|
|
sdkStepUrl: DOCUMENT_SDK_STEP_MAP[docType],
|
|
triggeredBy: [],
|
|
})
|
|
}
|
|
}
|
|
|
|
requiredDocs.sort((a, b) => {
|
|
if (a.requirement === 'mandatory' && b.requirement !== 'mandatory') return -1
|
|
if (a.requirement !== 'mandatory' && b.requirement === 'mandatory') return 1
|
|
const priorityOrder: Record<string, number> = { high: 3, medium: 2, low: 1 }
|
|
return priorityOrder[b.priority] - priorityOrder[a.priority]
|
|
})
|
|
|
|
return requiredDocs
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Risk flags
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getMaturityRecommendation(ruleId: string): string {
|
|
const recommendations: Record<string, string> = {
|
|
'HT-I01': 'Prozess für Betroffenenrechte (DSAR) etablieren und dokumentieren',
|
|
'HT-I02': 'Löschkonzept gemäß Art. 17 DSGVO entwickeln und implementieren',
|
|
'HT-I03':
|
|
'Incident-Response-Plan für Datenschutzverletzungen (Art. 33 DSGVO) erstellen',
|
|
'HT-I04': 'Regelmäßige interne Audits und Reviews einführen',
|
|
'HT-I05': 'Schulungsprogramm für Mitarbeiter zum Datenschutz etablieren',
|
|
}
|
|
return recommendations[ruleId] || 'Prozess etablieren und dokumentieren'
|
|
}
|
|
|
|
/**
|
|
* Evaluate risk flags based on process-maturity gaps and other risks.
|
|
*
|
|
* `checkTriggerFn` is injected to avoid a circular dependency on the engine.
|
|
*/
|
|
export function evaluateRiskFlags(
|
|
answers: ScopeProfilingAnswer[],
|
|
level: ComplianceDepthLevel,
|
|
checkTriggerFn: (
|
|
rule: HardTriggerRule,
|
|
answerMap: Map<string, any>,
|
|
answers: ScopeProfilingAnswer[],
|
|
) => boolean,
|
|
): RiskFlag[] {
|
|
const flags: RiskFlag[] = []
|
|
const answerMap = new Map(answers.map((a) => [a.questionId, a.value]))
|
|
|
|
const maturityRules = HARD_TRIGGER_RULES.filter((r) => r.category === 'process_maturity')
|
|
for (const rule of maturityRules) {
|
|
if (checkTriggerFn(rule, answerMap, answers)) {
|
|
flags.push({
|
|
severity: 'medium',
|
|
category: 'process',
|
|
message: rule.description,
|
|
legalReference: rule.legalReference,
|
|
recommendation: getMaturityRecommendation(rule.id),
|
|
})
|
|
}
|
|
}
|
|
|
|
if (getDepthLevelNumeric(level) >= 2) {
|
|
const encTransit = answerMap.get('tech_encryption_transit')
|
|
const encRest = answerMap.get('tech_encryption_rest')
|
|
|
|
if (encTransit === false) {
|
|
flags.push({
|
|
severity: 'high',
|
|
category: 'technical',
|
|
message: 'Fehlende Verschlüsselung bei Datenübertragung',
|
|
legalReference: 'Art. 32 DSGVO',
|
|
recommendation: 'TLS 1.2+ für alle Datenübertragungen implementieren',
|
|
})
|
|
}
|
|
|
|
if (encRest === false) {
|
|
flags.push({
|
|
severity: 'high',
|
|
category: 'technical',
|
|
message: 'Fehlende Verschlüsselung gespeicherter Daten',
|
|
legalReference: 'Art. 32 DSGVO',
|
|
recommendation: 'Verschlüsselung at-rest für sensitive Daten implementieren',
|
|
})
|
|
}
|
|
}
|
|
|
|
const thirdCountry = answerMap.get('tech_third_country')
|
|
const hostingLocation = answerMap.get('tech_hosting_location')
|
|
if (
|
|
thirdCountry === true &&
|
|
hostingLocation !== 'eu' &&
|
|
hostingLocation !== 'eu_us_adequacy'
|
|
) {
|
|
flags.push({
|
|
severity: 'high',
|
|
category: 'legal',
|
|
message: 'Drittlandtransfer ohne angemessene Garantien',
|
|
legalReference: 'Art. 44 ff. DSGVO',
|
|
recommendation:
|
|
'Standardvertragsklauseln (SCCs) oder Binding Corporate Rules (BCRs) implementieren',
|
|
})
|
|
}
|
|
|
|
const hasDPO = answerMap.get('org_has_dpo')
|
|
const employeeCount = answerMap.get('org_employee_count')
|
|
if (hasDPO === false && parseEmployeeCount(employeeCount as string) >= 250) {
|
|
flags.push({
|
|
severity: 'medium',
|
|
category: 'organizational',
|
|
message: 'Kein Datenschutzbeauftragter bei großer Organisation',
|
|
legalReference: 'Art. 37 DSGVO',
|
|
recommendation: 'Bestellung eines Datenschutzbeauftragten prüfen',
|
|
})
|
|
}
|
|
|
|
return flags
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gap analysis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function calculateGaps(
|
|
answers: ScopeProfilingAnswer[],
|
|
level: ComplianceDepthLevel,
|
|
): ScopeGap[] {
|
|
const gaps: ScopeGap[] = []
|
|
const answerMap = new Map(answers.map((a) => [a.questionId, a.value]))
|
|
|
|
if (getDepthLevelNumeric(level) >= 3) {
|
|
const hasDSFA = answerMap.get('proc_regular_audits')
|
|
if (hasDSFA === false) {
|
|
gaps.push({
|
|
gapType: 'documentation',
|
|
severity: 'high',
|
|
description: 'Datenschutz-Folgenabschätzung (DSFA) fehlt',
|
|
requiredFor: level,
|
|
currentState: 'Keine DSFA durchgeführt',
|
|
targetState: 'DSFA für Hochrisiko-Verarbeitungen durchgeführt und dokumentiert',
|
|
effort: 16,
|
|
priority: 'high',
|
|
})
|
|
}
|
|
}
|
|
|
|
const hasDeletion = answerMap.get('proc_deletion_concept')
|
|
if (hasDeletion === false && getDepthLevelNumeric(level) >= 2) {
|
|
gaps.push({
|
|
gapType: 'process',
|
|
severity: 'medium',
|
|
description: 'Löschkonzept fehlt',
|
|
requiredFor: level,
|
|
currentState: 'Kein systematisches Löschkonzept',
|
|
targetState: 'Dokumentiertes Löschkonzept mit definierten Fristen',
|
|
effort: 10,
|
|
priority: 'high',
|
|
})
|
|
}
|
|
|
|
const hasDSAR = answerMap.get('proc_dsar_process')
|
|
if (hasDSAR === false) {
|
|
gaps.push({
|
|
gapType: 'process',
|
|
severity: 'high',
|
|
description: 'Prozess für Betroffenenrechte fehlt',
|
|
requiredFor: level,
|
|
currentState: 'Kein etablierter DSAR-Prozess',
|
|
targetState: 'Dokumentierter Prozess zur Bearbeitung von Betroffenenrechten',
|
|
effort: 8,
|
|
priority: 'high',
|
|
})
|
|
}
|
|
|
|
const hasIncident = answerMap.get('proc_incident_response')
|
|
if (hasIncident === false) {
|
|
gaps.push({
|
|
gapType: 'process',
|
|
severity: 'high',
|
|
description: 'Incident-Response-Plan fehlt',
|
|
requiredFor: level,
|
|
currentState: 'Kein Prozess für Datenschutzverletzungen',
|
|
targetState: 'Dokumentierter Incident-Response-Plan gemäß Art. 33 DSGVO',
|
|
effort: 12,
|
|
priority: 'high',
|
|
})
|
|
}
|
|
|
|
const hasTraining = answerMap.get('comp_training')
|
|
if (hasTraining === false && getDepthLevelNumeric(level) >= 2) {
|
|
gaps.push({
|
|
gapType: 'organizational',
|
|
severity: 'medium',
|
|
description: 'Datenschutzschulungen fehlen',
|
|
requiredFor: level,
|
|
currentState: 'Keine regelmäßigen Schulungen',
|
|
targetState: 'Etabliertes Schulungsprogramm für alle Mitarbeiter',
|
|
effort: 6,
|
|
priority: 'medium',
|
|
})
|
|
}
|
|
|
|
return gaps
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Next actions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function buildNextActions(
|
|
requiredDocuments: RequiredDocument[],
|
|
gaps: ScopeGap[],
|
|
): NextAction[] {
|
|
const actions: NextAction[] = []
|
|
|
|
for (const doc of requiredDocuments) {
|
|
if (doc.requirement === 'mandatory') {
|
|
actions.push({
|
|
actionType: 'create_document',
|
|
title: `${doc.label} erstellen`,
|
|
description: `Pflichtdokument für Compliance-Level erstellen`,
|
|
priority: doc.priority,
|
|
estimatedEffort: doc.estimatedEffort,
|
|
documentType: doc.documentType,
|
|
sdkStepUrl: doc.sdkStepUrl,
|
|
blockers: [],
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const gap of gaps) {
|
|
let actionType: NextAction['actionType'] = 'establish_process'
|
|
if (gap.gapType === 'documentation') actionType = 'create_document'
|
|
else if (gap.gapType === 'technical') actionType = 'implement_technical'
|
|
else if (gap.gapType === 'organizational') actionType = 'organizational_change'
|
|
|
|
actions.push({
|
|
actionType,
|
|
title: `Gap schließen: ${gap.description}`,
|
|
description: `Von "${gap.currentState}" zu "${gap.targetState}"`,
|
|
priority: gap.priority,
|
|
estimatedEffort: gap.effort,
|
|
blockers: [],
|
|
})
|
|
}
|
|
|
|
const priorityOrder: Record<string, number> = { high: 3, medium: 2, low: 1 }
|
|
actions.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority])
|
|
|
|
return actions
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reasoning (audit trail)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function buildReasoning(
|
|
scores: ComplianceScores,
|
|
triggers: TriggeredHardTrigger[],
|
|
level: ComplianceDepthLevel,
|
|
docs: RequiredDocument[],
|
|
): ScopeReasoning[] {
|
|
const reasoning: ScopeReasoning[] = []
|
|
|
|
reasoning.push({
|
|
step: 'score_calculation',
|
|
description: 'Risikobasierte Score-Berechnung aus Profiling-Antworten',
|
|
factors: [
|
|
`Risiko-Score: ${scores.risk_score}/10`,
|
|
`Komplexitäts-Score: ${scores.complexity_score}/10`,
|
|
`Assurance-Score: ${scores.assurance_need}/10`,
|
|
`Composite Score: ${scores.composite_score}/10`,
|
|
],
|
|
impact: `Score-basiertes Level: ${getLevelFromScore(scores.composite_score)}`,
|
|
})
|
|
|
|
if (triggers.length > 0) {
|
|
reasoning.push({
|
|
step: 'hard_trigger_evaluation',
|
|
description: `${triggers.length} Hard Trigger Rule(s) aktiviert`,
|
|
factors: triggers.map(
|
|
(t) => `${t.ruleId}: ${t.description}${t.legalReference ? ` (${t.legalReference})` : ''}`,
|
|
),
|
|
impact: `Höchstes Trigger-Level: ${getMaxTriggerLevel(triggers)}`,
|
|
})
|
|
}
|
|
|
|
reasoning.push({
|
|
step: 'level_determination',
|
|
description: 'Finales Compliance-Level durch Maximum aus Score und Triggers',
|
|
factors: [
|
|
`Score-Level: ${getLevelFromScore(scores.composite_score)}`,
|
|
`Trigger-Level: ${getMaxTriggerLevel(triggers)}`,
|
|
],
|
|
impact: `Finales Level: ${level}`,
|
|
})
|
|
|
|
const mandatoryDocs = docs.filter((d) => d.requirement === 'mandatory')
|
|
reasoning.push({
|
|
step: 'document_scope',
|
|
description: `Dokumenten-Scope für ${level} bestimmt`,
|
|
factors: [
|
|
`${mandatoryDocs.length} Pflichtdokumente`,
|
|
`${docs.length - mandatoryDocs.length} empfohlene Dokumente`,
|
|
],
|
|
impact: `Gesamtaufwand: ~${docs.reduce((sum, d) => sum + d.estimatedEffort, 0)} Stunden`,
|
|
})
|
|
|
|
return reasoning
|
|
}
|