Assessor_2.0/tests/importers/scanner-html-source-extract...

131 lines
7.9 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import maesterHtmlFixture from '../../fixtures/maester/test-results.synthetic.html?raw';
import purpleKnightHtmlFixture from '../../fixtures/purple-knight/security-assessment.synthetic.html?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';
import { linkResultSheets } from '../../src/profiles/purple-knight/result-linker';
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
function candidateValue(
candidate: NonNullable<Awaited<ReturnType<typeof importFile>>['extraction']>['candidates'][number],
label: string
): string | undefined {
return candidate.fields.find(field => field.label === label)?.decodedValue;
}
async function canonicalCount(content: string, fileName: string, type: string): Promise<number> {
const dataset = await importFile(new File([content], fileName, { type }), { profiles: importProfiles });
const preview = previewEntryFromDataset(dataset, {
sha256: fileName.padEnd(64, '0').slice(0, 64), fileName, sizeBytes: content.length, lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch(fileName, [entry], '2026-08-25T08:00:00.000Z').model;
assertCanonicalAssessmentModel(model);
return model.findingOccurrences.length;
}
describe('script-embedded scanner HTML source extractors', () => {
it('extracts Maester tests from inert embedded JSON without executing report scripts', async () => {
delete (window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted;
const imported = await importFile(new File([maesterHtmlFixture], 'Maester-Synthetic-Report.html', { type: 'text/html' }), {
profiles: importProfiles
});
expect((window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted).toBeUndefined();
expect(imported.profileId).toBe('maester');
expect(imported.extraction?.extractor.id).toBe('maester-html-source-extractor');
expect(imported.extraction?.candidates).toHaveLength(2);
expect(candidateValue(imported.extraction!.candidates[0], 'ID')).toBe('MT.9001');
expect(candidateValue(imported.extraction!.candidates[0], 'Result')).toBe('Failed');
expect(candidateValue(imported.extraction!.candidates[0], 'ResultDetail')).toBe('The synthetic policy needs review.');
expect(imported.extraction!.candidates[0].source.objectPath).toBe('$.Tests[0]');
expect(await canonicalCount(maesterHtmlFixture, 'Maester-Synthetic-Report.html', 'text/html')).toBe(2);
});
it('extracts PurpleKnight indicators and preserves source-object provenance', async () => {
delete (window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted;
const imported = await importFile(new File(
[purpleKnightHtmlFixture], 'Security_Assessment_Report_synthetic.html', { type: 'text/html' }
), { profiles: importProfiles });
expect((window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted).toBeUndefined();
expect(imported.profileId).toBe('purple-knight');
expect(imported.extraction?.extractor.id).toBe('purple-knight-html-source-extractor');
expect(imported.extraction?.candidates).toHaveLength(2);
expect(imported.extraction?.candidates[0].fields.slice(0, 26).map(field => field.label)).toEqual([
'ShortName', 'Name', 'SI version', 'Target', 'Status', 'Runtime', 'Score', 'Severity', 'Weight', 'Category',
'MITRE ATT&CK_1', 'MITRE ATT&CK_2', 'MITRE ATT&CK_3', 'MITRE ATT&CK_4',
'ANSSI_1', 'ANSSI_2', 'ANSSI_3', 'ANSSI_4', 'MITRE D3FEND_1', 'MITRE D3FEND_2',
'Description', 'Likelihood of compromise', 'Result message', 'Number of results', 'Result', 'Remediation'
]);
expect(candidateValue(imported.extraction!.candidates[0], 'ShortName')).toBe('SI900001');
expect(candidateValue(imported.extraction!.candidates[0], 'Status')).toBe('IOE Found');
expect(candidateValue(imported.extraction!.candidates[0], 'Result')).toBe('SI900001');
expect(candidateValue(imported.extraction!.candidates[0], 'Description')).toBe('A synthetic description.');
expect(candidateValue(imported.extraction!.candidates[0], 'Category')).toBe('Synthetic Category');
expect(imported.extraction!.candidates[0].source.objectPath).toContain('$.Category_1');
expect(imported.extraction?.supportingTables).toEqual([expect.objectContaining({
name: 'SI900001', headers: ['Object', 'EventTimestamp', 'Ignored'],
rows: [['Synthetic value', '5/27/2026 12:37:57 PM', 'False']]
})]);
expect(imported.sheets.find(sheet => sheet.sourceSheet === 'SI900001')?.rows)
.toEqual([['Object', 'EventTimestamp', 'Ignored'], ['Synthetic value', '5/27/2026 12:37:57 PM', 'False']]);
expect(await canonicalCount(
purpleKnightHtmlFixture, 'Security_Assessment_Report_synthetic.html', 'text/html'
)).toBe(2);
});
it('links embedded PurpleKnight HTML result tables like workbook result sheets', async () => {
const imported = await importFile(new File(
[purpleKnightHtmlFixture], 'Security_Assessment_Report_synthetic.html', { type: 'text/html' }
), { profiles: importProfiles });
const preview = previewEntryFromDataset(imported, {
sha256: 'purple-knight-html-result'.padEnd(64, '0').slice(0, 64),
fileName: imported.fileName, sizeBytes: purpleKnightHtmlFixture.length, lastModified: 0
});
const model = createCanonicalImportBatch(
'purple-knight-html-result', [confirmEntryMappings(preview, importProfiles)], '2026-08-25T08:00:00.000Z'
).model;
const dataset = canonicalModelToDataset(model)!;
const mainSheet = dataset.sheets.find(sheet => sheet.analysis.records.some(record =>
record.fields.some(field => field.label === 'Source Rule ID' && field.value === 'SI900001')))!;
const record = mainSheet.analysis.records.find(item =>
item.fields.some(field => field.label === 'Source Rule ID' && field.value === 'SI900001'))!;
const linked = linkResultSheets(record, dataset.sheets, mainSheet.id);
expect(linked).toHaveLength(1);
expect(linked[0].sourceSheet).toBe('SI900001');
expect(linked[0].rows).toEqual([
['Object', 'EventTimestamp', 'Ignored'], ['Synthetic value', '5/27/2026 12:37:57 PM', 'False']
]);
});
it('does not treat an empty Maester report template as a finding export', async () => {
const template = '<!doctype html><html><body><script>var placeholder={"Tests":[],"EndOfJson":true};</script></body></html>';
await expect(importFile(new File([template], 'ReportTemplate.html', { type: 'text/html' }), { profiles: importProfiles }))
.rejects.toThrow(/keine auswertbaren Tabellen/);
});
it('extracts all tenants from a Maester multi-tenant HTML report', async () => {
const report = `<!doctype html><html><body><div>Maester Security Tests</div><script>
const data={"Tenants":[
{"Result":"Passed","Tests":[{"Id":"MT.9101","Title":"Synthetic tenant one","Result":"Passed","Severity":"Low"}],"EndOfJson":true},
{"Result":"Failed","Tests":[{"Id":"MT.9102","Title":"Synthetic tenant two","Result":"Failed","Severity":"High"}],"EndOfJson":true}
],"EndOfJson":true};
</script></body></html>`;
const imported = await importFile(new File([report], 'Maester-MultiTenant-Synthetic.html', { type: 'text/html' }), {
profiles: importProfiles
});
expect(imported.profileId).toBe('maester');
expect(imported.extraction?.candidates.map(candidate => candidate.source.objectPath)).toEqual([
'$.Tenants[0].Tests[0]', '$.Tenants[1].Tests[0]'
]);
});
});