Assessor_2.0/src/domain/canonical-validation.ts

306 lines
18 KiB
TypeScript

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<T extends { id: string }>(
items: T[],
path: string,
issues: ModelValidationIssue[]
): Set<string> {
const ids = new Set<string>();
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<string>();
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(', ')}`);
}