192 lines
15 KiB
TypeScript
192 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it } from 'vitest';
|
|
import * as XLSX from 'xlsx';
|
|
import type { JsonValue } from '../../src/domain/canonical-model';
|
|
import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation';
|
|
import { importFile } from '../../src/importers/importer';
|
|
import { importSource } from '../../src/importers/engine';
|
|
import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters';
|
|
import { importProfiles } from '../../src/profiles/profile-catalog';
|
|
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
|
|
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
|
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
|
|
import { linkResultSheets } from '../../src/profiles/purple-knight/result-linker';
|
|
import { processingReadiness } from '../../src/domain/processing-compatibility';
|
|
import { ImportError } from '../../src/shared/errors';
|
|
|
|
const names = ['SI900001', 'SI900002'];
|
|
const indicators = names.map((ShortName, index) => ({
|
|
ResIndicator: { ShortName, UUID: `id-${index}`, Name: `Synthetic check ${index}`, Version: '1', Severity: 'High', Weight: 10, CategoryID: 1,
|
|
Selected: true, IsEvaluated: true, IsFailed: index === 0, ExposureType: index === 0 ? 'IOE' : 'None', Targets: ['AD'],
|
|
Description: '<p>Synthetic description.</p>', StartTime: '2026-01-15T09:30:00.0000000Z', CompletionTime: '2026-01-15T09:30:01.2300000Z',
|
|
SecurityFrameworks: [{ Name: 'MITRE ATT&CK', Tags: ['T1', 'T2', 'T3', 'T4', 'T5'] }], Future: { Empty: '', Zero: 0, Flag: false } },
|
|
ExecutionResult: { Status: index === 0 ? 'Failed' : 'Pass', Score: index === 0 ? 10 : 0, ResultMessage: 'Synthetic result', Remediation: '<p>Review it.</p>' },
|
|
IndicatorReportObjects: index === 0 ? [['Object: synthetic', 'EventTimestamp: 1/15/2026 9:30:57 AM', 'Ignored: False']] : [], TotalResultsCount: index === 0 ? 1 : 0
|
|
}));
|
|
const root = {
|
|
AppVersion: 'synthetic', Edition: 'Community', GeneralConfig: { Categories: [{ ID: 1, Name: 'Synthetic' }] }, FutureMetadata: { unknown: [false, 0, ''] },
|
|
IndicatorsTotalCount: { 'id-0': 1 }, reportResultsList: [{ ADType: 'AD', ForestName: 'synthetic.example', SelectedDomains: ['synthetic.example'], UserName: 'synthetic runner',
|
|
ReportResults: { TotalScore: 50, TotalCount: 2, TotalEvaluatedCount: 2, PassedCount: 1, IOEsCount: 1, IOCsCount: 0, NotRelevant: 0, UnselectedCount: 0, GeneratedOn: '2026-01-15 09:30', TotalSelectedCount: 2 },
|
|
ReportCategories: [{ Category: { ID: 1, Name: 'Synthetic', Score: 75 }, Indicators: ['id-0', 'id-1'] }] }]
|
|
};
|
|
const header = ['ShortName', 'Name', 'SI version', 'Status', 'Severity', 'Weight', 'Number of results', 'Result'];
|
|
const rows = indicators.map((item, index) => [item.ResIndicator.ShortName, item.ResIndicator.Name, '1', index === 0 ? 'IOE Found' : 'Pass', 'High', 10, item.TotalResultsCount, index === 0 ? names[0] : '']);
|
|
const summary = [['Tool edition', 'Community'], ['Tool version', 'synthetic'], ['AD: Security posture score', 50], ['AD: Forest name', 'synthetic.example'], ['AD: Number of domains', 1], ['Selected indicators', 2], ['AD: Passed', 1], ['AD: IOEs found', 1], ['AD category score: Synthetic', 75]];
|
|
|
|
function html(data: unknown = root, items: unknown[] = indicators): string {
|
|
return `<html><body><script>window.reportJSON = ${JSON.stringify(data)};</script><script>${items.map((item, index) => `window["Category_1"]["id-${index}"] = ${JSON.stringify(item)};`).join('\n')}</script><script>window.__pkExecuted = true;</script></body></html>`;
|
|
}
|
|
async function parse(content: string, name = 'report.html') {
|
|
return importFile(new File([content], name), { profiles: importProfiles });
|
|
}
|
|
function canonical(dataset: Awaited<ReturnType<typeof parse>>) {
|
|
const preview = previewEntryFromDataset(dataset, { fileName: dataset.fileName, sha256: 'a'.repeat(64), sizeBytes: 1, lastModified: 0 });
|
|
const result = createCanonicalImportBatch('synthetic-pk', [confirmEntryMappings(preview, importProfiles)], '2026-09-09T00:00:00Z').model;
|
|
assertCanonicalAssessmentModel(result);
|
|
return result;
|
|
}
|
|
async function expectDiagnostic(content: string, code: string) {
|
|
let caught: unknown;
|
|
try { await parse(content); } catch (error) { caught = error; }
|
|
expect(caught).toBeInstanceOf(ImportError);
|
|
expect((caught as ImportError).diagnostics.some(item => item.code === code)).toBe(true);
|
|
}
|
|
|
|
describe('PurpleKnight full-report integrity', () => {
|
|
it('imports workbook findings alongside its summary and resolves retained detail sheets', async () => {
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(summary), 'Assessment summary');
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([header, ...rows]), 'Indicators results');
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Object', 'EventTimestamp'], ['synthetic', '2026-01-15 09:30:57']]), names[0]);
|
|
const bytes = new Uint8Array(XLSX.write(workbook, {type: 'array', bookType: 'xlsx'}));
|
|
const browser = await importFile(new File([bytes], 'report.xlsx'), {profiles:importProfiles});
|
|
const server = await importSource({metadata:{name:'report.xlsx',sizeBytes:bytes.length,lastModified:0},bytes}, {profiles:importProfiles}, nodeImportRuntimeAdapters);
|
|
expect(browser.extraction).toEqual(server.extraction);
|
|
const model = canonical(browser);
|
|
expect(model.findingOccurrences).toHaveLength(2);
|
|
expect(model.environmentObservations).toHaveLength(summary.length);
|
|
const projected = canonicalModelToDataset(model)!;
|
|
const main = projected.sheets.find(sheet => sheet.sourceSheet === 'Indicators results')!;
|
|
expect(linkResultSheets(main.analysis.records[0], projected.sheets, main.id)).toHaveLength(1);
|
|
expect(model.findingOccurrences.map(item => item.status.normalizedValue)).toEqual(['failed', 'passed']);
|
|
});
|
|
|
|
it('extracts HTML metadata and the same semantic summary values as CSV', async () => {
|
|
const htmlModel = canonical(await parse(html()));
|
|
const csvModel = canonical(await parse(summary.map(row => row.join(';')).join('\n'), 'Assessment_summary.csv'));
|
|
for (const metric of csvModel.environmentObservations ?? []) {
|
|
expect(htmlModel.environmentObservations?.find(item => item.property === metric.property)?.normalizedValue, metric.property).toEqual(metric.normalizedValue);
|
|
}
|
|
expect(htmlModel.environmentObservations?.find(item => item.property === 'purple_knight.source.FutureMetadata')?.originalValue).toEqual(root.FutureMetadata);
|
|
expect(htmlModel.reportSections).toHaveLength(1);
|
|
expect((window as Window & { __pkExecuted?: boolean }).__pkExecuted).toBeUndefined();
|
|
});
|
|
|
|
it('preserves exact native field paths, original values, unknown fields and framework overflow', async () => {
|
|
const imported = await parse(html());
|
|
const fields = imported.extraction!.candidates[0].fields;
|
|
const resolve = (path: string): JsonValue | undefined => {
|
|
const parts = [...path.matchAll(/\.([\w]+)|\["([^"]+)"\]|\[(\d+)\]/g)].map(match => match[1] ?? match[2] ?? match[3]);
|
|
let current: unknown = { Category_1: { 'id-0': indicators[0], 'id-1': indicators[1] } };
|
|
for (const key of parts) current = (current as Record<string, unknown>)?.[key];
|
|
return current as JsonValue | undefined;
|
|
};
|
|
for (const field of fields) if (field.source.objectPath) expect(field.originalValue, field.label).toEqual(resolve(field.source.objectPath));
|
|
expect(fields.find(field => field.label === 'Source ResIndicator.Future')?.originalValue).toEqual(indicators[0].ResIndicator.Future);
|
|
expect(fields.find(field => field.label === 'Source ResIndicator.SecurityFrameworks')?.originalValue).toEqual(indicators[0].ResIndicator.SecurityFrameworks);
|
|
expect(fields.find(field => field.label === 'Runtime')?.decodedValue).toBe('00:00:01:23');
|
|
expect(fields.find(field => field.label === 'Status')?.decodedValue).toBe('IOE Found');
|
|
expect(fields.find(field => field.label === 'Category')?.decodedValue).toBe('Synthetic');
|
|
expect(imported.extraction!.supportingTables![0].rows[0][1]).toBe('1/15/2026 9:30:57 AM');
|
|
const model = canonical(imported);
|
|
expect(model.findingOccurrences[0].originalFields.find(field => field.label === 'Source ResIndicator.Future')?.value).toEqual(indicators[0].ResIndicator.Future);
|
|
const work = canonicalModelToDataset(model)!;
|
|
const record = work.sheets.find(sheet => !sheet.isProjectSheet && sheet.analysis.records.some(record => record.fields.some(field => field.label === 'Source Rule ID')))?.analysis.records[0];
|
|
expect(record?.fields.find(field => field.label === 'Status')?.value).toBe('IOE Found');
|
|
expect(record?.fields.find(field => field.label === 'Category')?.value).toBe('Synthetic');
|
|
});
|
|
|
|
it('uses structural HTML recognition even behind the raw-text detection limit', async () => {
|
|
const imported = await parse(html().replace('<body>', `<body><!--${'x'.repeat(300_000)}-->`), 'neutral.html');
|
|
expect(imported.profileId).toBe('purple-knight');
|
|
expect(imported.recognition?.source.requiresConfirmation).toBe(false);
|
|
});
|
|
|
|
it('ignores report-like text in comments and strings without executing anything', async () => {
|
|
const decoy = `<script>// window.reportJSON = invalid();\nconst text='window["Category_1"]["fake"] = invalid();';</script>`;
|
|
expect((await parse(html().replace('<body>', `<body>${decoy}`))).extraction?.candidates).toHaveLength(2);
|
|
});
|
|
|
|
it('ignores fake script elements in attributes, HTML comments and raw-text containers', async () => {
|
|
const decoy = '<script>window.reportJSON = invalid();</script>';
|
|
const content = html().replace('<body>', `<body><!--${decoy}--><textarea>${decoy}</textarea><div title='${decoy}'></div>`)
|
|
.replace('<script>window.reportJSON', '<script data-note="a > b">window.reportJSON');
|
|
expect((await parse(content)).extraction?.candidates).toHaveLength(2);
|
|
});
|
|
|
|
it.each([
|
|
['missing result object', html().replace('"ResIndicator":', '"MissingResIndicator":'), 'purple-knight-indicator-invalid'],
|
|
['missing native ID', html().replace('"ShortName":"SI900001",', ''), 'purple-knight-id-missing'],
|
|
['malformed JSON', html().replace('"IsFailed":true', '"IsFailed":INVALID'), 'purple-knight-payload-invalid'],
|
|
['executable payload', html().replace('window.reportJSON = {', 'window.reportJSON = run({'), 'purple-knight-payload-invalid'],
|
|
['conflicting roots', html().replace('</body>', `<script>window.reportJSON = ${JSON.stringify(root)};</script></body>`), 'purple-knight-binding-duplicate'],
|
|
['malformed details', html(root, [{...indicators[0], IndicatorReportObjects: [42]}, indicators[1]]), 'purple-knight-detail-invalid'],
|
|
['unreadable nonempty report', html(root, []), 'purple-knight-results-missing'],
|
|
['missing report summary', html().replace('window.reportJSON', 'window.unrelated'), 'purple-knight-summary-missing']
|
|
])('rejects %s explicitly', async (_case, content, code) => expectDiagnostic(content, code));
|
|
|
|
it.each(['TotalCount', 'PassedCount', 'IOEsCount', 'NotRelevant', 'TotalEvaluatedCount'])('diagnoses a wrong %s without dropping results', async key => {
|
|
const modified = structuredClone(root);
|
|
Object.assign(modified.reportResultsList[0].ReportResults, {[key]:99});
|
|
const imported = await parse(html(modified));
|
|
expect(imported.extraction?.processing.status).toBe('partial');
|
|
expect(imported.extraction?.diagnostics.some(item => item.code === 'purple-knight-count-mismatch')).toBe(true);
|
|
expect(canonical(imported).findingOccurrences).toHaveLength(2);
|
|
});
|
|
|
|
it('reports detail-count mismatches and duplicate identities while retaining original evidence', async () => {
|
|
const duplicate = {...indicators[1],ResIndicator:{...indicators[1].ResIndicator,ShortName:names[0]}};
|
|
const imported = await parse(html(root,[{...indicators[0],TotalResultsCount:5},duplicate]));
|
|
expect(imported.extraction?.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining(['purple-knight-id-duplicate','purple-knight-count-mismatch']));
|
|
expect(canonical(imported).findingOccurrences).toHaveLength(2);
|
|
});
|
|
|
|
it('treats prototype-like unknown status text as source data, not a counter key', async () => {
|
|
const unknown = {...indicators[1],ExecutionResult:{...indicators[1].ExecutionResult,Status:'constructor'}};
|
|
const imported = await parse(html(root,[indicators[0],unknown]));
|
|
expect(canonical(imported).findingOccurrences).toHaveLength(2);
|
|
expect(imported.extraction?.diagnostics.map(item => item.code)).toContain('purple-knight-count-mismatch');
|
|
expect(JSON.stringify(imported.extraction)).not.toContain('function Object');
|
|
});
|
|
|
|
it('preserves a declared empty report and its zero metrics', async () => {
|
|
const empty = {...root,reportResultsList:[{ADType:'AD',ReportResults:{TotalCount:0,PassedCount:0,TotalScore:0},ReportCategories:[]}]};
|
|
const model = canonical(await parse(html(empty,[])));
|
|
expect(model.findingOccurrences).toHaveLength(0);
|
|
expect(model.environmentObservations?.find(item => item.property === 'purple_knight.ad.security_posture_score')?.normalizedValue).toBe(0);
|
|
});
|
|
|
|
it('accepts explicit null details for an indicator with no results', async () => {
|
|
const imported = await parse(html(root,[indicators[0],{...indicators[1],IndicatorReportObjects:null}]));
|
|
expect(canonical(imported).findingOccurrences).toHaveLength(2);
|
|
expect(imported.extraction?.supportingTables).toHaveLength(1);
|
|
expect(imported.extraction?.diagnostics).toEqual([]);
|
|
expect(imported.extraction?.candidates[1].fields.find(field => field.label === 'Result')).toMatchObject({ originalValue:null, source:{objectPath:'$.Category_1["id-1"].IndicatorReportObjects'} });
|
|
});
|
|
|
|
it('recognizes a native all-passed CSV by name and schema, but not filename alone', async () => {
|
|
const name = 'Security_Assessment_Report_synthetic_2026_01_15_09_30_00_(Indicators_results).csv';
|
|
const imported = await parse([header,...rows.map(row => row.map((cell,index) => index === 3 ? 'Pass' : cell))].map(row => row.join(';')).join('\n'), name);
|
|
expect(imported.profileId).toBe('purple-knight');
|
|
expect(imported.recognition?.source.requiresConfirmation).toBe(false);
|
|
const unrelated = await parse('Title;Value\nSynthetic;1',name);
|
|
expect(unrelated.profileId).toBe('generic');
|
|
});
|
|
|
|
it('keeps old imports compatible and recommends an explicit reimport', () => {
|
|
expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.2.0',mappingVersion:'1.1.0'})).toBe('reimport-recommended');
|
|
expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.3.0',mappingVersion:'1.1.0'})).toBe('reimport-recommended');
|
|
expect(processingReadiness('purple-knight',{parserId:'synthetic',parserVersion:'1',profileId:'purple-knight',profileVersion:'1.4.0',mappingVersion:'1.1.0'})).toBe('current');
|
|
});
|
|
});
|