import type { CanonicalAssessmentModel, FindingOccurrence, ImportFileRecord, MappingConfidence, SourcePosition } from './canonical-model'; import { processingManifest } from './processing-compatibility'; const RESULT_CONTRACT_RESULT_TYPES = new Set([ 'exposure_finding', 'vulnerability_finding', 'compliance_check', 'policy_deviation', 'informational', 'unknown' ]); const RESULT_CONTRACT_EXPOSURE_STATES = new Set([ 'detected', 'not_detected', 'not_evaluated', 'execution_error', 'unknown' ]); export type ModelValidationSeverity = 'error' | 'warning'; export type ModelValidationCode = | 'invalid-schema-version' | 'duplicate-id' | 'missing-reference' | 'inconsistent-reference' | 'invalid-confidence' | 'invalid-number' | 'missing-original-fields' | 'invalid-source-position' | 'invalid-finding-identity' | 'duplicate-canonical-key' | 'source-artifact-not-retained' | 'invalid-result-contract-facet' | 'invalid-environment-context' | 'invalid-processing-version'; export interface ModelValidationIssue { severity: ModelValidationSeverity; code: ModelValidationCode; path: string; message: string; } function addIssue( issues: ModelValidationIssue[], severity: ModelValidationSeverity, code: ModelValidationCode, path: string, message: string ) { issues.push({ severity, code, path, message }); } function idSet( items: T[], path: string, issues: ModelValidationIssue[] ): Set { const ids = new Set(); items.forEach((item, index) => { if (ids.has(item.id)) addIssue(issues, 'error', 'duplicate-id', `${path}[${index}].id`, `ID '${item.id}' ist mehrfach vorhanden.`); ids.add(item.id); }); return ids; } function validateConfidence(confidence: MappingConfidence, path: string, issues: ModelValidationIssue[]) { if (!Number.isFinite(confidence.score) || confidence.score < 0 || confidence.score > 1) { addIssue(issues, 'error', 'invalid-confidence', `${path}.score`, 'Konfidenz muss zwischen 0 und 1 liegen.'); } } function validatePosition(position: SourcePosition, path: string, issues: ModelValidationIssue[]) { (['rowIndex', 'columnIndex'] as const).forEach(field => { const value = position[field]; if (value !== undefined && (!Number.isInteger(value) || value < 0)) { addIssue(issues, 'error', 'invalid-source-position', `${path}.${field}`, `${field} muss eine nicht negative Ganzzahl sein.`); } }); } function validateDefinitionIdentity( definition: CanonicalAssessmentModel['findingDefinitions'][number], path: string, issues: ModelValidationIssue[] ) { const identity = definition.identity; if (!identity) return; const unresolved = identity.strategy === 'unresolved'; const knownStrategy = ['tool-rule-id', 'framework-control', 'unresolved'].includes(identity.strategy); if (identity.version !== 1 || !knownStrategy || unresolved === identity.durable || (unresolved && !definition.canonicalKey.startsWith('unresolved:')) || (!unresolved && definition.canonicalKey.startsWith('unresolved:')) || (identity.strategy === 'framework-control' && !definition.frameworkReferences.length) || !identity.evidence.length) { addIssue(issues, 'error', 'invalid-finding-identity', `${path}.identity`, 'Finding-Identitätsstrategie, Dauerhaftigkeit, Schlüssel und Evidenz sind widersprüchlich.'); } } function validateOccurrenceValues(occurrence: FindingOccurrence, path: string, issues: ModelValidationIssue[]) { validateConfidence(occurrence.severity.provenance.confidence, `${path}.severity.provenance.confidence`, issues); validateConfidence(occurrence.status.provenance.confidence, `${path}.status.provenance.confidence`, issues); if (occurrence.outcome) validateConfidence(occurrence.outcome.provenance.confidence, `${path}.outcome.provenance.confidence`, issues); if (occurrence.technicalExecutionState) { validateConfidence(occurrence.technicalExecutionState.provenance.confidence, `${path}.technicalExecutionState.provenance.confidence`, issues); } if (occurrence.score) validateConfidence(occurrence.score.provenance.confidence, `${path}.score.provenance.confidence`, issues); if (occurrence.affectedAsset) validateConfidence(occurrence.affectedAsset.provenance.confidence, `${path}.affectedAsset.provenance.confidence`, issues); occurrence.fieldMappings.forEach((mapping, index) => validateConfidence(mapping.provenance.confidence, `${path}.fieldMappings[${index}].provenance.confidence`, issues) ); validatePosition(occurrence.sourcePosition, `${path}.sourcePosition`, issues); if (!occurrence.originalFields.length) { addIssue(issues, 'error', 'missing-original-fields', `${path}.originalFields`, 'Ein Finding-Vorkommen muss seine ursprünglichen Quellfelder behalten.'); } } /** * RESULT_CONTRACT-Invarianten (docs/RESULT_CONTRACT.md, Abschnitt * "Invarianten" plus die additiv realisierte Exklusivität aus dem * gelieferten CANONICAL_MODEL-DDL, dort per Trigger vorgesehen). Prüft nur * Felder, die am Vorkommen explizit gesetzt sind; unbesetzte Felder werden * beim Persistieren aus tool_id/outcome/technicalExecutionState abgeleitet * (src/domain/result-contract-mapping.ts) und sind hier nicht zu beanstanden. */ function validateResultContractFacets(occurrence: FindingOccurrence, path: string, issues: ModelValidationIssue[]) { const { resultType, isIoe, exposureState, ioeRuleId, ioeRuleVersion } = occurrence; if (resultType !== undefined && !RESULT_CONTRACT_RESULT_TYPES.has(resultType)) { addIssue(issues, 'error', 'invalid-result-contract-facet', `${path}.resultType`, `Unbekannter result_type '${resultType}'.`); } if (exposureState !== undefined && !RESULT_CONTRACT_EXPOSURE_STATES.has(exposureState)) { addIssue(issues, 'error', 'invalid-result-contract-facet', `${path}.exposureState`, `Unbekannter exposure_state '${exposureState}'.`); } // exposure_state gesetzt ⇒ result_type = exposure_finding. if (exposureState !== undefined && resultType !== undefined && resultType !== 'exposure_finding') { addIssue(issues, 'error', 'invalid-result-contract-facet', `${path}.exposureState`, 'exposure_state ist nur bei result_type = exposure_finding zulässig.'); } // is_ioe darf laut RESULT_CONTRACT nie per Default true sein außerhalb von // exposure_finding + exposure_state = detected. if (isIoe === true) { if (resultType !== undefined && resultType !== 'exposure_finding') { addIssue(issues, 'error', 'invalid-result-contract-facet', `${path}.isIoe`, 'is_ioe darf nur bei result_type = exposure_finding true sein.'); } if (exposureState !== undefined && exposureState !== 'detected') { addIssue(issues, 'error', 'invalid-result-contract-facet', `${path}.isIoe`, 'is_ioe = true erfordert exposure_state = detected.'); } if (!ioeRuleId?.trim() || !ioeRuleVersion?.trim()) { addIssue(issues, 'error', 'invalid-result-contract-facet', `${path}.isIoe`, 'is_ioe = true benötigt ioe_rule_id und ioe_rule_version.'); } } } function validateImportFile(file: ImportFileRecord, path: string, issues: ModelValidationIssue[]) { if (!Number.isInteger(file.sizeBytes) || file.sizeBytes < 0) { addIssue(issues, 'error', 'invalid-number', `${path}.sizeBytes`, 'Dateigröße muss eine nicht negative Ganzzahl sein.'); } validateConfidence(file.detection, `${path}.detection`, issues); const processing = file.processedWith; if (![processing.parserId, processing.parserVersion, processing.profileId, processing.profileVersion, processing.mappingVersion] .every(value => Boolean(value?.trim()))) { addIssue(issues, 'error', 'invalid-processing-version', `${path}.processedWith`, 'Parser-, Profil- und Mappingstand müssen vollständig angegeben sein.'); } const manifest = processingManifest(processing.profileId, processing.profileVersion); if (manifest && (processing.mappingVersion !== manifest.mappingVersion || processing.normalizationVersion && processing.normalizationVersion !== manifest.normalizationVersion || processing.presentationVersion && processing.presentationVersion !== manifest.presentationVersion || processing.compatibilityKey && processing.compatibilityKey !== manifest.compatibilityKey)) { addIssue(issues, 'error', 'invalid-processing-version', `${path}.processedWith`, 'Der persistierte Verarbeitungsstand widerspricht dem registrierten Kompatibilitätsvertrag.'); } if (file.sourceArtifact.kind === 'not-retained') { addIssue(issues, 'warning', 'source-artifact-not-retained', `${path}.sourceArtifact`, 'Die vollständige Quelldatei wird nicht aufbewahrt.'); } } export function validateCanonicalAssessmentModel(model: CanonicalAssessmentModel): ModelValidationIssue[] { const issues: ModelValidationIssue[] = []; if (model.schemaVersion !== 1) addIssue(issues, 'error', 'invalid-schema-version', 'schemaVersion', 'Nur Schema-Version 1 wird unterstützt.'); const scanRunIds = idSet(model.scanRuns, 'scanRuns', issues); const importFileIds = idSet(model.importFiles, 'importFiles', issues); const sourceTableIds = idSet(model.sourceTables, 'sourceTables', issues); idSet(model.reportSections ?? [], 'reportSections', issues); idSet(model.environmentObservations ?? [], 'environmentObservations', issues); const definitionIds = idSet(model.findingDefinitions, 'findingDefinitions', issues); const canonicalKeys = new Set(); model.findingDefinitions.forEach((definition, index) => { validateDefinitionIdentity(definition, `findingDefinitions[${index}]`, issues); if (canonicalKeys.has(definition.canonicalKey)) addIssue(issues, 'error', 'duplicate-canonical-key', `findingDefinitions[${index}].canonicalKey`, 'Kanonischer Finding-Schlüssel ist mehrfach vorhanden.'); canonicalKeys.add(definition.canonicalKey); }); const occurrenceIds = idSet(model.findingOccurrences, 'findingOccurrences', issues); idSet(model.evidence, 'evidence', issues); idSet(model.reviews, 'reviews', issues); idSet(model.diagnostics, 'diagnostics', issues); if (model.assessment.projectId !== model.project.id) { addIssue(issues, 'error', 'inconsistent-reference', 'assessment.projectId', 'Assessment verweist nicht auf das enthaltene Projekt.'); } model.scanRuns.forEach((run, index) => { if (run.assessmentId !== model.assessment.id) { addIssue(issues, 'error', 'inconsistent-reference', `scanRuns[${index}].assessmentId`, 'Scan-Lauf verweist nicht auf das enthaltene Assessment.'); } if (!Number.isInteger(run.sequence) || run.sequence < 1) { addIssue(issues, 'error', 'invalid-number', `scanRuns[${index}].sequence`, 'Scan-Reihenfolge muss eine positive Ganzzahl sein.'); } }); const importFilesById = new Map(model.importFiles.map(file => [file.id, file])); model.importFiles.forEach((file, index) => { if (!scanRunIds.has(file.scanRunId)) addIssue(issues, 'error', 'missing-reference', `importFiles[${index}].scanRunId`, 'Zugehöriger Scan-Lauf fehlt.'); validateImportFile(file, `importFiles[${index}]`, issues); }); const definitionsById = new Map(model.findingDefinitions.map(definition => [definition.id, definition])); const sourceTablesById = new Map(model.sourceTables.map(table => [table.id, table])); model.sourceTables.forEach((table, index) => { if (!importFileIds.has(table.importFileId)) addIssue(issues, 'error', 'missing-reference', `sourceTables[${index}].importFileId`, 'Zugehörige Importdatei fehlt.'); if (!Number.isInteger(table.index) || table.index < 0 || !Number.isInteger(table.rowCount) || table.rowCount < 0 || !Number.isInteger(table.columnCount) || table.columnCount < 0) { addIssue(issues, 'error', 'invalid-number', `sourceTables[${index}]`, 'Tabellenindex und Dimensionen müssen nicht negative Ganzzahlen sein.'); } }); (model.reportSections ?? []).forEach((section, index) => { const path = `reportSections[${index}]`; if (!scanRunIds.has(section.scanRunId)) addIssue(issues, 'error', 'missing-reference', `${path}.scanRunId`, 'Scan-Lauf für Reportabschnitt fehlt.'); const file = importFilesById.get(section.importFileId); if (!file) addIssue(issues, 'error', 'missing-reference', `${path}.importFileId`, 'Importdatei für Reportabschnitt fehlt.'); else if (file.scanRunId !== section.scanRunId) addIssue(issues, 'error', 'inconsistent-reference', `${path}.importFileId`, 'Reportabschnitt und Importdatei gehören zu unterschiedlichen Scan-Läufen.'); if (!section.sectionKey.trim() || !section.label.trim()) addIssue(issues, 'error', 'invalid-environment-context', path, 'Reportabschnitt benötigt Schlüssel und Bezeichnung.'); validatePosition(section.source, `${path}.source`, issues); }); (model.environmentObservations ?? []).forEach((observation, index) => { const path = `environmentObservations[${index}]`; if (!scanRunIds.has(observation.scanRunId)) addIssue(issues, 'error', 'missing-reference', `${path}.scanRunId`, 'Scan-Lauf für Umgebungsbeobachtung fehlt.'); const file = importFilesById.get(observation.importFileId); if (!file) addIssue(issues, 'error', 'missing-reference', `${path}.importFileId`, 'Importdatei für Umgebungsbeobachtung fehlt.'); else if (file.scanRunId !== observation.scanRunId) addIssue(issues, 'error', 'inconsistent-reference', `${path}.importFileId`, 'Umgebungsbeobachtung und Importdatei gehören zu unterschiedlichen Scan-Läufen.'); if (!observation.subject.type.trim() || !observation.property.trim() || !observation.label.trim()) { addIssue(issues, 'error', 'invalid-environment-context', path, 'Umgebungsbeobachtung benötigt Subjekttyp, Property und Bezeichnung.'); } validatePosition(observation.source, `${path}.source`, issues); }); model.findingOccurrences.forEach((occurrence, index) => { const path = `findingOccurrences[${index}]`; if (!definitionIds.has(occurrence.definitionId)) addIssue(issues, 'error', 'missing-reference', `${path}.definitionId`, 'Finding-Definition fehlt.'); const definition = definitionsById.get(occurrence.definitionId); if (definition?.identity?.strategy === 'tool-rule-id' && !(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId)?.trim()) { addIssue(issues, 'error', 'invalid-finding-identity', `${path}.sourceIdentity`, 'Eine native dauerhafte Finding-Identität benötigt die ursprüngliche Regel-ID am Vorkommen.'); } if (definition?.identity?.strategy === 'unresolved' && (occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId)?.trim()) { addIssue(issues, 'error', 'invalid-finding-identity', `${path}.sourceIdentity`, 'Eine als nicht aufgelöst markierte Definition darf keine dauerhafte native Regel-ID am Vorkommen tragen.'); } if (!scanRunIds.has(occurrence.scanRunId)) addIssue(issues, 'error', 'missing-reference', `${path}.scanRunId`, 'Scan-Lauf fehlt.'); const importFile = importFilesById.get(occurrence.importFileId); if (!importFile) addIssue(issues, 'error', 'missing-reference', `${path}.importFileId`, 'Importdatei fehlt.'); else if (importFile.scanRunId !== occurrence.scanRunId) addIssue(issues, 'error', 'inconsistent-reference', `${path}.importFileId`, 'Importdatei und Finding-Vorkommen gehören zu unterschiedlichen Scan-Läufen.'); else if (occurrence.mappingRuleVersion && occurrence.mappingRuleVersion !== importFile.processedWith.mappingVersion) { addIssue(issues, 'error', 'invalid-processing-version', `${path}.mappingRuleVersion`, 'Der Mappingstand des Findings stimmt nicht mit seiner Importdatei überein.'); } if (occurrence.sourceTableId) { const table = sourceTablesById.get(occurrence.sourceTableId); if (!table) addIssue(issues, 'error', 'missing-reference', `${path}.sourceTableId`, 'Quelltabelle fehlt.'); else if (table.importFileId !== occurrence.importFileId) addIssue(issues, 'error', 'inconsistent-reference', `${path}.sourceTableId`, 'Quelltabelle und Finding-Vorkommen gehören zu unterschiedlichen Dateien.'); } validateOccurrenceValues(occurrence, path, issues); validateResultContractFacets(occurrence, path, issues); }); model.evidence.forEach((evidence, index) => { if (!occurrenceIds.has(evidence.occurrenceId)) addIssue(issues, 'error', 'missing-reference', `evidence[${index}].occurrenceId`, 'Finding-Vorkommen für Evidenz fehlt.'); if (evidence.sourcePosition) validatePosition(evidence.sourcePosition, `evidence[${index}].sourcePosition`, issues); }); model.reviews.forEach((review, index) => { if (review.target.kind === 'definition' && !definitionIds.has(review.target.definitionId)) { addIssue(issues, 'error', 'missing-reference', `reviews[${index}].target.definitionId`, 'Finding-Definition für Review fehlt.'); } if (review.target.kind === 'occurrence' && !occurrenceIds.has(review.target.occurrenceId)) { addIssue(issues, 'error', 'missing-reference', `reviews[${index}].target.occurrenceId`, 'Finding-Vorkommen für Review fehlt.'); } if (!Number.isInteger(review.revision) || review.revision < 1) { addIssue(issues, 'error', 'invalid-number', `reviews[${index}].revision`, 'Review-Revision muss eine positive Ganzzahl sein.'); } }); model.diagnostics.forEach((diagnostic, index) => { if (!importFileIds.has(diagnostic.importFileId)) addIssue(issues, 'error', 'missing-reference', `diagnostics[${index}].importFileId`, 'Importdatei für Diagnose fehlt.'); if (diagnostic.sourceTableId && !sourceTableIds.has(diagnostic.sourceTableId)) addIssue(issues, 'error', 'missing-reference', `diagnostics[${index}].sourceTableId`, 'Quelltabelle für Diagnose fehlt.'); if (diagnostic.sourcePosition) validatePosition(diagnostic.sourcePosition, `diagnostics[${index}].sourcePosition`, issues); }); return issues; } export function assertCanonicalAssessmentModel(model: CanonicalAssessmentModel): void { const errors = validateCanonicalAssessmentModel(model).filter(issue => issue.severity === 'error'); if (!errors.length) return; throw new Error(`Ungültiges kanonisches Assessment-Modell: ${errors.map(issue => `${issue.code} (${issue.path})`).join(', ')}`); }