Assessor_2.0/tests/importers/defender-for-iot.test.ts

179 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import { buildDefenderVulnerabilityModel, type DefenderSourceRow, type DefenderSourceTable } from '../../src/domain/defender-for-iot';
import { importFile } from '../../src/importers/importer';
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { importProfiles } from '../../src/profiles/profile-catalog';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness';
const summaryHeaders = ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'];
const detailHeaders = ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung'];
function table(tableIndex: number, headers: string[], rows: Array<Record<string, unknown>>): DefenderSourceTable {
return {
tableIndex,
name: headers.includes('CVE') ? 'Vulnerability detail' : 'Asset summary',
headers,
rows: rows.map((fields, index): DefenderSourceRow => ({ tableIndex, rowIndex: index + 1, fields: fields as DefenderSourceRow['fields'] }))
};
}
function summary(name: string, ip: string, site: string, counts: [number, number, number, number], sensor = 'sensor-a') {
return { Name: name, IP: ip, Site: site, Sensor: sensor, TotalCVEs: counts.reduce((sum, value) => sum + value, 0), Critical: counts[0], High: counts[1], Medium: counts[2], Low: counts[3] };
}
function detail(name: string, ip: string, site: string, sensor: string, cve: string, severity: string, description = 'Synthetic description', nullValue: unknown = null) {
return { Name: name, IP: ip, Site: site, Sensor: sensor, CVE: cve, Severity: severity, NULL: nullValue, Beschreibung: description };
}
describe('Microsoft Defender for IoT vulnerability model', () => {
it('models CVEs, devices, occurrences, observations, and aggregate-only counts independently', () => {
const model = buildDefenderVulnerabilityModel([
table(0, summaryHeaders, [
summary('00:11:22:33:44:55', '10.0.0.1', 'North', [2, 3, 0, 1]),
summary('device-b', '10.0.0.2', 'North', [1, 0, 0, 0])
]),
table(1, detailHeaders, [
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical'),
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-b', 'CVE-2026-0001', 'Critical'),
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0002', 'Critical'),
detail('device-b', '10.0.0.2', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical')
])
]);
expect(model.cves).toHaveLength(2);
expect(model.devices).toHaveLength(2);
expect(model.occurrences).toHaveLength(3);
expect(model.observations).toHaveLength(4);
expect(model.cves.find(item => item.id === 'CVE-2026-0001')).toMatchObject({ deviceIds: expect.arrayContaining([expect.any(String), expect.any(String)]), observationCount: 3 });
expect(model.occurrences.find(item => item.cve === 'CVE-2026-0001' && item.observations.length === 2)?.sensors).toEqual(['sensor-a', 'sensor-b']);
expect(model.devices.find(item => item.displayName === '00:11:22:33:44:55')?.severity.high).toMatchObject({ detail: 0, summary: 3, aggregateOnly: 3, state: 'aggregate-only' });
expect(model.aggregateOnlyCount).toBe(4);
expect(model.sites[0]).toMatchObject({ assetCount: 2, uniqueCveCount: 2, affectedDeviceCount: 2, rawObservationCount: 4 });
});
it('uses MAC, IP, and site-scoped name identities without merging a reused IP across sites', () => {
const model = buildDefenderVulnerabilityModel([table(0, detailHeaders, [
detail('AA-BB-CC-DD-EE-FF', '10.0.0.1', 'A', 's1', 'CVE-2026-0101', 'High'),
detail('device-ip', '10.0.0.2', 'A', 's1', 'CVE-2026-0102', 'High'),
detail('device-name', '', 'A', 's1', 'CVE-2026-0103', 'Medium'),
detail('device-reused-a', '10.0.0.9', 'A', 's1', 'CVE-2026-0104', 'Low'),
detail('device-reused-b', '10.0.0.9', 'B', 's2', 'CVE-2026-0104', 'Low'),
detail('', '', '', '', 'CVE-2026-0105', 'Critical')
])]);
expect(model.devices.map(item => item.identityMethod)).toEqual(expect.arrayContaining(['mac', 'ip', 'name', 'unresolved']));
expect(model.devices.filter(item => item.ip === '10.0.0.9')).toHaveLength(2);
expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([
'defender-device-identity-site-scoped', 'defender-device-identity-unresolved'
]));
});
it('keeps duplicate rows as evidence and flags conflicts, unexpected NULL data, and reconciliation failures', () => {
const duplicate = detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0201', 'Critical', 'Description A');
const model = buildDefenderVulnerabilityModel([
table(0, summaryHeaders, [
{ ...summary('device-a', '', 'A', [3, 0, 0, 0]), TotalCVEs: 99 },
summary('device-a', '', 'A', [2, 0, 0, 0])
]),
table(1, detailHeaders, [
duplicate,
{ ...duplicate },
detail('device-a', '', 'A', 'sensor-b', 'CVE-2026-0201', 'High', 'Description B', 'preserve-me'),
detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0202', 'Critical')
])
]);
expect(model.occurrences).toHaveLength(2);
expect(model.observations).toHaveLength(4);
expect(model.observations.some(item => item.additionalSourceFields.NULL === 'preserve-me')).toBe(true);
expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([
'defender-summary-total-mismatch',
'defender-summary-conflict',
'defender-duplicate-source-observation',
'defender-null-column-populated',
'defender-cve-severity-conflict',
'defender-cve-description-conflict',
'defender-summary-detail-mismatch'
]));
});
it('distinguishes zero, unavailable detail, and single-sheet variants', () => {
const summaryOnly = buildDefenderVulnerabilityModel([table(0, summaryHeaders, [summary('device-a', '', 'A', [0, 2, 0, 0])])]);
const detailOnly = buildDefenderVulnerabilityModel([table(0, detailHeaders, [detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0301', 'Medium')])]);
expect(summaryOnly.available).toBe(true);
expect(summaryOnly.occurrences).toHaveLength(0);
expect(summaryOnly.devices[0].severity.critical.state).toBe('zero');
expect(summaryOnly.devices[0].severity.high.state).toBe('aggregate-only');
expect(detailOnly.devices[0].severity.medium.state).toBe('detail-only');
expect(detailOnly.devices[0].severity.low.state).toBe('unavailable');
});
it('selects the versioned profile and emits one candidate per device and CVE', async () => {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
summary('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', [1, 2, 0, 0])
], { header: summaryHeaders }), 'Assets');
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-a', 'CVE-2026-0401', 'Critical'),
detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-b', 'CVE-2026-0401', 'Critical')
], { header: detailHeaders }), 'Vulnerabilities');
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
const imported = await importFile(new File([bytes], 'defender-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles });
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
expect(imported.profileVersion).toBe('1.0.0');
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' });
expect(imported.extraction?.candidates).toHaveLength(1);
expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(2);
expect(imported.mapping?.candidates).toHaveLength(1);
expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([1, 2]);
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
}), importProfiles);
const canonical = createCanonicalImportBatch('defender-synthetic', [entry], '2026-09-10T06:00:00.000Z').model;
expect(canonical.importFiles[0]).toMatchObject({
detectedTool: { id: 'defender-for-iot-vulnerabilities' },
processedWith: {
profileId: 'defender-for-iot-vulnerabilities',
profileVersion: '1.0.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v1'
}
});
expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] });
expect(canonical.findingDefinitions).toHaveLength(1);
expect(canonical.findingOccurrences).toHaveLength(1);
expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toContain('mac:001122334455');
expect(canonical.evidence).toHaveLength(1);
expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) });
expect(canonical.evidence[0].value).toHaveLength(2);
expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(1);
});
it('keeps a summary-only import viewable without creating finding definitions or occurrences', async () => {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
summary('synthetic-device', '', 'Synthetic site', [0, 2, 0, 0])
], { header: summaryHeaders }), 'Assets');
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
const imported = await importFile(new File([bytes], 'defender-summary-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles });
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
sha256: 'e'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
}), importProfiles);
const canonical = createCanonicalImportBatch('defender-summary-synthetic', [entry], '2026-09-10T06:00:00.000Z').model;
const projected = canonicalModelToDataset(canonical);
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
expect(canonical.findingDefinitions).toHaveLength(0);
expect(canonical.findingOccurrences).toHaveLength(0);
expect(projected?.profileId).toBe('defender-for-iot-vulnerabilities');
expect(projected?.sheets.some(sheet => sheet.analysis.headers.includes('TotalCVEs'))).toBe(true);
});
});