222 lines
12 KiB
TypeScript
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' }
|
|
});
|
|
});
|
|
});
|
|
|