Assessor_2.0/tests/importers/canonical-candidate-migrati...

222 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import cisCatFixture from '../../fixtures/cis-cat/assessment.synthetic.html?raw';
import genericFixture from '../../fixtures/generic/findings.synthetic.csv?raw';
import maesterFixture from '../../fixtures/maester/test-results.synthetic.csv?raw';
import pingCastleFixture from '../../fixtures/pingcastle/healthcheck.synthetic.html?raw';
import purpleKnightFixture from '../../fixtures/purple-knight/indicator-results.synthetic.json?raw';
import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { importFile } from '../../src/importers/importer';
import { importProfiles } from '../../src/profiles/profile-catalog';
const fixtures = [
{ id: 'purple-knight', name: 'Indicator-results.synthetic.json', type: 'application/json', content: purpleKnightFixture },
{ id: 'pingcastle', name: 'ad_hc_synthetic.example.html', type: 'text/html', content: pingCastleFixture },
{ id: 'cis-cat', name: 'CIS-CAT-SYNTHETIC.html', type: 'text/html', content: cisCatFixture },
{ id: 'maester', name: 'TestResults-synthetic.csv', type: 'text/csv', content: maesterFixture },
{ id: 'generic', name: 'findings.synthetic.csv', type: 'text/csv', content: genericFixture }
] as const;
describe('canonical candidate migration', () => {
it.each(fixtures)('persists $id through the confirmed candidate mapping', async fixture => {
const dataset = await importFile(new File([fixture.content], fixture.name, { type: fixture.type }), {
profiles: importProfiles
});
expect(dataset.mapping).toBeDefined();
expect(dataset.profileId).toBe(fixture.id);
const previewEntry = previewEntryFromDataset(dataset, {
sha256: fixture.id.padEnd(64, '0').slice(0, 64),
fileName: fixture.name,
sizeBytes: fixture.content.length,
lastModified: 0
});
if (previewEntry.mappingsToConfirm > 0) expect(() => createCanonicalImportBatch(
`unconfirmed-${fixture.id}`, [previewEntry], '2026-07-20T14:00:00.000Z'
)).toThrow(/unbestätigten/);
const entry = confirmEntryMappings(previewEntry, importProfiles);
const confirmedDataset = entry.dataset!;
const selectedTables = new Set(confirmedDataset.recognition?.tables
.filter(table => table.selected)
.map(table => table.tableIndex));
const expectedCandidates = confirmedDataset.mapping!.candidates.filter(candidate =>
candidate.source.tableIndex === undefined || selectedTables.has(candidate.source.tableIndex));
expect(expectedCandidates.length).toBeGreaterThan(0);
expect(entry.mappingsToConfirm).toBe(0);
expect(entry.recordCount).toBe(expectedCandidates.length);
if (fixture.id === 'purple-knight') {
expect(entry.dataset?.mapping?.fieldDecisions.some(decision =>
decision.sourceScope.tableIndex === undefined && decision.decisionSource === 'user-confirmed')).toBe(true);
}
const model = createCanonicalImportBatch(`migration-${fixture.id}`, [entry], '2026-07-20T14:00:00.000Z').model;
assertCanonicalAssessmentModel(model);
expect(model.findingOccurrences).toHaveLength(expectedCandidates.length);
const excludedCandidateCount = confirmedDataset.mapping!.candidates.length - expectedCandidates.length;
if (excludedCandidateCount > 0) expect(model.diagnostics).toContainEqual(expect.objectContaining({
code: 'candidate-table-not-selected', details: { excludedCandidateCount }
}));
expect(model.importFiles[0].processedWith).toMatchObject({
parserId: `${dataset.format.toLocaleLowerCase('en-US')}-format-adapter`,
profileId: dataset.mapping!.mappingProfile.id,
profileVersion: dataset.profileVersion,
mappingVersion: dataset.mapping!.mappingProfile.version
});
expect(model.findingOccurrences.every(occurrence => occurrence.sourceIdentity.tool.id === fixture.id)).toBe(true);
expect(model.findingOccurrences[0].originalFields.map(field => ({ key: field.key, value: field.value })))
.toEqual(expectedCandidates[0].fields.map(field => ({ key: field.key, value: field.originalValue })));
if (fixture.id === 'cis-cat') {
const firstCandidateTitle = expectedCandidates[0].fields.find(field => field.role === 'title')?.decodedValue;
expect(firstCandidateTitle).toBeTruthy();
expect(model.findingDefinitions[0].title).toBe(firstCandidateTitle);
expect(model.findingDefinitions[0].title).not.toBe('Source: File');
expect(model.findingDefinitions[0].title).not.toBe(fixture.name);
}
});
it('persists non-finding workbook tables for linked PurpleKnight results', async () => {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([
['ShortName', 'Name', 'Status', 'Result'],
['SI000055', 'Synthetic permission finding', 'IOE Found', 'SI000055']
]), 'Indicators results');
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([
['Attribute', 'DistinguishedName', 'Ignored'],
['nTSecurityDescriptor', 'CN=Synthetic', false]
]), 'SI000055');
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
const source = new File([bytes], 'Security_Assessment_Report_synthetic.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const dataset = await importFile(source, { profiles: importProfiles });
const preview = previewEntryFromDataset(dataset, {
sha256: 'purple-knight-linked-results'.padEnd(64, '0').slice(0, 64),
fileName: source.name,
sizeBytes: source.size,
lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch('purple-knight-linked-results', [entry], '2026-07-22T16:00:00.000Z').model;
const resultTable = model.sourceTables.find(table => table.name === 'SI000055');
expect(model.findingOccurrences).toHaveLength(1);
expect(resultTable?.rows).toHaveLength(1);
expect(resultTable?.rows?.[0].fields.map(field => field.value)).toEqual([
'nTSecurityDescriptor', 'CN=Synthetic', 'FALSE'
]);
assertCanonicalAssessmentModel(model);
});
it('produces the same canonical meaning for equivalent CSV and native JSON candidates', async () => {
const sources = [
{ name: 'equivalent.csv', type: 'text/csv', content: 'Rule ID;Title;Severity;Status\nFORMAT-1;Equivalent finding;High;Open' },
{ name: 'equivalent.json', type: 'application/json', content: JSON.stringify([{ 'Rule ID': 'FORMAT-1', Title: 'Equivalent finding', Severity: 'High', Status: 'Open' }]) }
];
const models = await Promise.all(sources.map(async (source, index) => {
const dataset = await importFile(new File([source.content], source.name, { type: source.type }), { profiles: importProfiles });
const preview = previewEntryFromDataset(dataset, {
sha256: `equivalent-${index}`.padEnd(64, '0').slice(0, 64),
fileName: source.name,
sizeBytes: source.content.length,
lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
return createCanonicalImportBatch(`equivalent-${index}`, [entry], '2026-07-20T14:00:00.000Z').model;
}));
expect(models.map(model => ({
definition: {
canonicalKey: model.findingDefinitions[0].canonicalKey,
title: model.findingDefinitions[0].title
},
occurrence: {
severity: model.findingOccurrences[0].severity.normalizedValue,
status: model.findingOccurrences[0].status.normalizedValue,
ruleId: model.findingOccurrences[0].sourceIdentity.canonicalRuleId
}
}))).toEqual([
{
definition: { canonicalKey: 'generic:format-1', title: 'Equivalent finding' },
occurrence: { severity: 'high', status: 'open', ruleId: 'FORMAT-1' }
},
{
definition: { canonicalKey: 'generic:format-1', title: 'Equivalent finding' },
occurrence: { severity: 'high', status: 'open', ruleId: 'FORMAT-1' }
}
]);
});
it('preserves native object paths instead of persisting the JSON compatibility table as provenance', async () => {
const dataset = await importFile(new File([purpleKnightFixture], 'Indicator-results.synthetic.json', {
type: 'application/json'
}), { profiles: importProfiles });
const previewEntry = previewEntryFromDataset(dataset, {
sha256: 'native-json'.padEnd(64, '0').slice(0, 64),
fileName: dataset.fileName,
sizeBytes: dataset.fileSize,
lastModified: dataset.lastModified
});
if (previewEntry.mappingsToConfirm > 0) expect(() => createCanonicalImportBatch(
'unconfirmed-native-json', [previewEntry], '2026-07-20T14:00:00.000Z'
)).toThrow(/unbestätigten/);
const entry = confirmEntryMappings(previewEntry, importProfiles);
const model = createCanonicalImportBatch('native-json', [entry], '2026-07-20T14:00:00.000Z').model;
expect(dataset.mapping?.extractor.id).toBe('native-object-source-extractor');
expect(model.findingOccurrences[0]).toMatchObject({
sourceTableId: undefined,
sourcePosition: { objectPath: '$' }
});
expect(model.findingOccurrences[0].originalFields.every(field => field.path?.startsWith('$.'))).toBe(true);
});
it('keeps repeated HTML table names uniquely addressable through canonical persistence', async () => {
const repeatedTables = Array.from({ length: 12 }, (_, index) => `
<table><caption>Repeated section</caption>
<tr><th>Rule ID</th><th>Title</th><th>Severity</th></tr>
<tr><td>SYN-${index + 1}</td><td>Synthetic finding ${index + 1}</td><td>Medium</td></tr>
</table>`).join('');
const source = new File([
`<html><head><title>PingCastle synthetic report</title></head><body>${repeatedTables}</body></html>`
], 'ad_hc_synthetic.repeated.html', { type: 'text/html' });
const dataset = await importFile(source, { profiles: importProfiles });
const preview = previewEntryFromDataset(dataset, {
sha256: 'repeated-html-tables'.padEnd(64, '0').slice(0, 64),
fileName: source.name,
sizeBytes: source.size,
lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch('repeated-html-tables', [entry], '2026-07-21T14:00:00.000Z').model;
expect(new Set(dataset.intermediate?.tables.map(table => table.id)).size).toBe(12);
expect(new Set(dataset.sheets.map(sheet => sheet.id)).size).toBe(12);
expect(new Set(model.sourceTables.map(table => table.id)).size).toBe(12);
expect(model.sourceTables.map(table => table.index)).toEqual(Array.from({ length: 12 }, (_, index) => index));
assertCanonicalAssessmentModel(model);
});
it('keeps a technical execution failure separate from the check outcome', async () => {
const content = 'Rule ID;Title;Severity;Status\nEXEC-1;Synthetic execution failure;High;Failed to run';
const source = new File([content], 'execution-failure.synthetic.csv', { type: 'text/csv' });
const dataset = await importFile(source, { profiles: importProfiles });
const preview = previewEntryFromDataset(dataset, {
sha256: 'execution-failure'.padEnd(64, '0').slice(0, 64),
fileName: source.name,
sizeBytes: source.size,
lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch('execution-failure', [entry], '2026-07-22T08:00:00.000Z').model;
expect(model.findingOccurrences[0]).toMatchObject({
status: { originalValue: 'Failed to run', normalizedValue: 'error' },
outcome: { originalValue: 'Failed to run', normalizedValue: 'execution_error' },
technicalExecutionState: { originalValue: 'Failed to run', normalizedValue: 'failed' }
});
});
});