227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type {
|
|
CanonicalAssessmentModel,
|
|
CanonicalFindingStatus,
|
|
CanonicalSeverity,
|
|
FindingOccurrence,
|
|
ImportFileRecord,
|
|
MappingProvenance,
|
|
ScanRun,
|
|
ToolIdentity
|
|
} from '../../src/domain/canonical-model';
|
|
import { assertCanonicalAssessmentModel, validateCanonicalAssessmentModel } from '../../src/domain/canonical-validation';
|
|
|
|
const timestamp = '2026-07-17T08:00:00.000Z';
|
|
|
|
const provenance = (mappingVersion = 'mapping-1'): MappingProvenance => ({
|
|
decisionSource: 'automatic',
|
|
confidence: { level: 'high', score: 0.95, reasons: ['synthetic-test-signal'] },
|
|
mappingVersion
|
|
});
|
|
|
|
const tools: Record<string, ToolIdentity> = {
|
|
purple: { id: 'purple-knight', name: 'Purple Knight', version: 'synthetic' },
|
|
ping: { id: 'pingcastle', name: 'PingCastle', version: 'synthetic' },
|
|
cis: { id: 'cis-cat', name: 'CIS-CAT', version: 'synthetic' },
|
|
maester: { id: 'maester', name: 'Maester', version: 'synthetic' }
|
|
};
|
|
|
|
const run = (id: string, sequence: number, tool: ToolIdentity): ScanRun => ({
|
|
id,
|
|
assessmentId: 'assessment-1',
|
|
sequence,
|
|
label: `${tool.name} run ${sequence}`,
|
|
tool,
|
|
importedAt: timestamp
|
|
});
|
|
|
|
const importFile = (id: string, scanRunId: string, tool: ToolIdentity, format: string): ImportFileRecord => ({
|
|
id,
|
|
scanRunId,
|
|
fileName: `${id}.${format.toLocaleLowerCase()}`,
|
|
format,
|
|
sizeBytes: 100,
|
|
importedAt: timestamp,
|
|
detectedTool: tool,
|
|
detection: { level: 'high', score: 0.98, reasons: ['fixture marker'] },
|
|
processedWith: {
|
|
parserId: `${format.toLocaleLowerCase()}-parser`,
|
|
parserVersion: '1',
|
|
profileId: tool.id,
|
|
profileVersion: '1',
|
|
mappingVersion: '1'
|
|
},
|
|
sourceArtifact: { kind: 'embedded', bytesBase64: 'AA==' }
|
|
});
|
|
|
|
function occurrence(input: {
|
|
id: string;
|
|
definitionId: string;
|
|
scanRunId: string;
|
|
importFileId: string;
|
|
sourceTableId: string;
|
|
tool: ToolIdentity;
|
|
ruleId: string;
|
|
title: string;
|
|
originalSeverity: string;
|
|
severity: CanonicalSeverity;
|
|
originalStatus: string;
|
|
status: CanonicalFindingStatus;
|
|
}): FindingOccurrence {
|
|
return {
|
|
id: input.id,
|
|
definitionId: input.definitionId,
|
|
scanRunId: input.scanRunId,
|
|
importFileId: input.importFileId,
|
|
sourceTableId: input.sourceTableId,
|
|
sourceIdentity: {
|
|
tool: input.tool,
|
|
originalRuleId: input.ruleId,
|
|
canonicalRuleId: input.definitionId,
|
|
originalTitle: input.title
|
|
},
|
|
sourcePosition: { rowIndex: 1 },
|
|
originalFields: [
|
|
{ key: 'id', label: 'ID', value: input.ruleId, columnIndex: 0 },
|
|
{ key: 'title', label: 'Title', value: input.title, columnIndex: 1 },
|
|
{ key: 'severity', label: 'Severity', value: input.originalSeverity, columnIndex: 2 },
|
|
{ key: 'status', label: 'Status', value: input.originalStatus, columnIndex: 3 }
|
|
],
|
|
fieldMappings: [
|
|
{ targetRole: 'rule-id', sourceFieldKeys: ['id'], normalizedValue: input.definitionId, provenance: provenance() },
|
|
{ targetRole: 'title', sourceFieldKeys: ['title'], normalizedValue: input.title, provenance: provenance() }
|
|
],
|
|
severity: { originalValue: input.originalSeverity, normalizedValue: input.severity, provenance: provenance() },
|
|
status: { originalValue: input.originalStatus, normalizedValue: input.status, provenance: provenance() }
|
|
};
|
|
}
|
|
|
|
function modelFixture(): CanonicalAssessmentModel {
|
|
const scanRuns = [
|
|
run('run-purple', 1, tools.purple),
|
|
run('run-ping-previous', 2, tools.ping),
|
|
run('run-ping-current', 3, tools.ping),
|
|
run('run-cis', 4, tools.cis),
|
|
run('run-maester', 5, tools.maester)
|
|
];
|
|
const importFiles = [
|
|
importFile('file-purple', 'run-purple', tools.purple, 'JSON'),
|
|
importFile('file-ping-previous', 'run-ping-previous', tools.ping, 'HTML'),
|
|
importFile('file-ping-current', 'run-ping-current', tools.ping, 'HTML'),
|
|
importFile('file-cis', 'run-cis', tools.cis, 'HTML'),
|
|
importFile('file-maester', 'run-maester', tools.maester, 'CSV')
|
|
];
|
|
const sourceTables = importFiles.map((file, index) => ({
|
|
id: `table-${index + 1}`,
|
|
importFileId: file.id,
|
|
name: 'Synthetic results',
|
|
index: 0,
|
|
rowCount: 2,
|
|
columnCount: 4,
|
|
attributes: {}
|
|
}));
|
|
const findingDefinitions = [
|
|
{ id: 'definition-purple', canonicalKey: 'purple:pk-001', title: 'Purple Knight synthetic finding', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp },
|
|
{ id: 'definition-ping', canonicalKey: 'pingcastle:s-synthetic', title: 'PingCastle synthetic finding', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp },
|
|
{ id: 'definition-cis', canonicalKey: 'cis:1.1.1', title: 'CIS-CAT synthetic finding', frameworkReferences: [{ framework: 'CIS', controlId: '1.1.1' }], createdAt: timestamp, updatedAt: timestamp },
|
|
{ id: 'definition-maester', canonicalKey: 'maester:mt.1001', title: 'Maester synthetic finding', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp }
|
|
];
|
|
const findingOccurrences = [
|
|
occurrence({ id: 'occurrence-purple', definitionId: 'definition-purple', scanRunId: 'run-purple', importFileId: 'file-purple', sourceTableId: 'table-1', tool: tools.purple, ruleId: 'PK-001', title: 'Purple Knight synthetic finding', originalSeverity: 'Severe', severity: 'critical', originalStatus: 'Failed', status: 'failed' }),
|
|
occurrence({ id: 'occurrence-ping-previous', definitionId: 'definition-ping', scanRunId: 'run-ping-previous', importFileId: 'file-ping-previous', sourceTableId: 'table-2', tool: tools.ping, ruleId: 'S-Synthetic', title: 'PingCastle synthetic finding', originalSeverity: '20 points', severity: 'high', originalStatus: 'Matched', status: 'open' }),
|
|
occurrence({ id: 'occurrence-ping-current', definitionId: 'definition-ping', scanRunId: 'run-ping-current', importFileId: 'file-ping-current', sourceTableId: 'table-3', tool: tools.ping, ruleId: 'S-Synthetic', title: 'PingCastle synthetic finding', originalSeverity: '20 points', severity: 'high', originalStatus: 'Matched', status: 'open' }),
|
|
occurrence({ id: 'occurrence-cis', definitionId: 'definition-cis', scanRunId: 'run-cis', importFileId: 'file-cis', sourceTableId: 'table-4', tool: tools.cis, ruleId: '1.1.1', title: 'CIS-CAT synthetic finding', originalSeverity: 'Level 1', severity: 'medium', originalStatus: 'Fail', status: 'failed' }),
|
|
occurrence({ id: 'occurrence-maester', definitionId: 'definition-maester', scanRunId: 'run-maester', importFileId: 'file-maester', sourceTableId: 'table-5', tool: tools.maester, ruleId: 'MT.1001', title: 'Maester synthetic finding', originalSeverity: 'High', severity: 'high', originalStatus: 'Failed', status: 'failed' })
|
|
];
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
project: { id: 'project-1', name: 'Synthetic project', createdAt: timestamp, updatedAt: timestamp, attributes: {} },
|
|
assessment: { id: 'assessment-1', projectId: 'project-1', name: 'Synthetic assessment', createdAt: timestamp, updatedAt: timestamp },
|
|
scanRuns,
|
|
importFiles,
|
|
sourceTables,
|
|
findingDefinitions,
|
|
findingOccurrences,
|
|
evidence: [{ id: 'evidence-1', occurrenceId: 'occurrence-maester', kind: 'text', value: 'Synthetic result detail', sourceFieldKeys: ['resultDetail'] }],
|
|
reviews: [{
|
|
id: 'review-1',
|
|
target: { kind: 'occurrence', occurrenceId: 'occurrence-maester' },
|
|
workflowStatus: 'open',
|
|
priority: 'high',
|
|
includeInActionPlan: true,
|
|
markers: [],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
revision: 1
|
|
}],
|
|
diagnostics: []
|
|
};
|
|
}
|
|
|
|
describe('canonical assessment model', () => {
|
|
it('represents all pilot tools and reuses one definition across scan runs', () => {
|
|
const model = modelFixture();
|
|
|
|
expect(validateCanonicalAssessmentModel(model)).toEqual([]);
|
|
expect(new Set(model.scanRuns.map(item => item.tool.id))).toEqual(new Set(['purple-knight', 'pingcastle', 'cis-cat', 'maester']));
|
|
expect(model.findingOccurrences.filter(item => item.definitionId === 'definition-ping')).toHaveLength(2);
|
|
expect(model.findingDefinitions.filter(item => item.id === 'definition-ping')).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps original values and normalized values independently', () => {
|
|
const model = modelFixture();
|
|
const purpleKnight = model.findingOccurrences.find(item => item.id === 'occurrence-purple');
|
|
|
|
expect(purpleKnight?.severity.originalValue).toBe('Severe');
|
|
expect(purpleKnight?.severity.normalizedValue).toBe('critical');
|
|
expect(purpleKnight?.originalFields).toContainEqual({ key: 'status', label: 'Status', value: 'Failed', columnIndex: 3 });
|
|
});
|
|
|
|
it('keeps review state outside imported finding occurrences', () => {
|
|
const model = modelFixture();
|
|
const occurrenceBefore = JSON.stringify(model.findingOccurrences[4]);
|
|
|
|
model.reviews[0].comment = 'Reviewed independently';
|
|
model.reviews[0].revision += 1;
|
|
|
|
expect(JSON.stringify(model.findingOccurrences[4])).toBe(occurrenceBefore);
|
|
expect(validateCanonicalAssessmentModel(model)).toEqual([]);
|
|
});
|
|
|
|
it('rejects contradictory identity metadata and duplicate canonical keys', () => {
|
|
const model = modelFixture();
|
|
model.findingDefinitions[0].identity = {
|
|
version: 1,
|
|
strategy: 'unresolved',
|
|
durable: true,
|
|
evidence: []
|
|
};
|
|
model.findingDefinitions[1].canonicalKey = model.findingDefinitions[0].canonicalKey;
|
|
|
|
const issues = validateCanonicalAssessmentModel(model);
|
|
expect(issues.map(issue => issue.code)).toEqual(expect.arrayContaining([
|
|
'invalid-finding-identity',
|
|
'duplicate-canonical-key'
|
|
]));
|
|
});
|
|
it('reports broken references, missing source data, and invalid confidence', () => {
|
|
const model = modelFixture();
|
|
model.findingOccurrences[0].definitionId = 'missing-definition';
|
|
model.findingOccurrences[0].originalFields = [];
|
|
model.findingOccurrences[0].severity.provenance.confidence.score = 1.5;
|
|
model.reviews[0].target = { kind: 'occurrence', occurrenceId: 'missing-occurrence' };
|
|
model.importFiles.push({ ...model.importFiles[0] });
|
|
|
|
const issues = validateCanonicalAssessmentModel(model);
|
|
|
|
expect(issues.map(issue => issue.code)).toEqual(expect.arrayContaining([
|
|
'duplicate-id',
|
|
'missing-reference',
|
|
'missing-original-fields',
|
|
'invalid-confidence'
|
|
]));
|
|
expect(() => assertCanonicalAssessmentModel(model)).toThrow('Ungültiges kanonisches Assessment-Modell');
|
|
});
|
|
});
|