// @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 { defenderModelForDataset } from '../../src/features/defender-for-iot/defender-dataset-model'; import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness'; import { createScannerUnifiedResult } from '../../src/domain/unified-result'; 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>): 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('joins the mock CVE and inventory schemas through the source-native Device ID', () => { const model = buildDefenderVulnerabilityModel([ table(0, ['Device ID', 'Device Name', 'IP Address', 'CVE', 'CVSS Score', 'Severity', 'Component', 'Status'], [{ 'Device ID': 'DEV-OT-001', 'Device Name': 'PLC-LINE1-01', 'IP Address': '192.0.2.21', CVE: 'CVE-2099-1001', 'CVSS Score': 9.1, Severity: 'Critical', Component: 'Synthetic component', Status: 'Open' }]), table(1, ['Name', 'IPv4 Address', 'MAC Address', 'Site', 'Sensor', 'Device ID'], [{ Name: 'PLC-LINE1-01', 'IPv4 Address': '192.0.2.21', 'MAC Address': '02:00:00:00:00:21', Site: 'Synthetic site', Sensor: 'SENS-01', 'Device ID': 'DEV-OT-001' }]) ]); expect(model).toMatchObject({ available: true, detailTableCount: 1, inventoryTableCount: 1, inventoryRowCount: 1 }); expect(model.devices).toHaveLength(1); expect(model.devices[0]).toMatchObject({ id: 'device-id:dev-ot-001', identityMethod: 'device-id', displayName: 'PLC-LINE1-01', ip: '192.0.2.21', site: 'Synthetic site', sensors: ['SENS-01'] }); expect(model.cves[0]).toMatchObject({ id: 'CVE-2099-1001', deviceIds: ['device-id:dev-ot-001'] }); expect(model.observations[0].additionalSourceFields).toMatchObject({ 'Device ID': 'DEV-OT-001', 'CVSS Score': 9.1, Component: 'Synthetic component', Status: 'Open' }); }); it('imports the synthetic Mock-Defender CVE and inventory CSVs with the Defender profile', async () => { const bytes = new TextEncoder().encode([ 'Device ID,Device Name,IP Address,Site,Sensor,CVE,CVSS Score,Severity,Component,Status', 'DEV-OT-001,PLC-LINE1-01,192.0.2.21,Riverbend Plant,SENS-RB-01,CVE-2099-1001,9.1,Critical,Synthetic component,Open', 'DEV-OT-002,HMI-LINE1-01,192.0.2.31,Riverbend Plant,SENS-RB-01,CVE-2099-1002,7.5,High,Synthetic component,Open', 'DEV-OT-004,HISTORIAN-01,192.0.2.51,Riverbend Plant,SENS-RB-01,CVE-2099-1005,8.2,High,Synthetic component,Mitigation planned', 'DEV-OT-005,RTU-WATER-01,192.0.2.61,Riverbend Plant,SENS-RB-01,CVE-2099-1006,6.4,Medium,Synthetic component,Accepted risk', 'DEV-OT-008,FIREWALL-OT-01,192.0.2.1,Riverbend Plant,SENS-RB-01,CVE-2099-1007,7.8,High,Synthetic component,Patch scheduled' ].join('\n')); const imported = await importFile(new File([bytes], 'cves.csv', { type: 'text/csv' }), { profiles: importProfiles }); const inventoryBytes = new TextEncoder().encode([ 'Name,IPv4 Address,MAC Address,Site,Sensor,Device Id', 'PLC-LINE1-01,192.0.2.21,02:00:00:00:00:21,Riverbend Plant,SENS-RB-01,DEV-OT-001' ].join('\n')); const inventory = await importFile(new File([inventoryBytes], 'device_inventory.csv', { type: 'text/csv' }), { profiles: importProfiles }); expect(imported.profileId).toBe('defender-for-iot-vulnerabilities'); expect(imported.profileVersion).toBe('1.2.0'); expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.1.0' }); expect(imported.extraction?.candidates).toHaveLength(5); const observations = imported.extraction?.candidates[0].fields.find(field => field.label === 'Source Observations')?.originalValue; expect(observations).toEqual(expect.arrayContaining([expect.objectContaining({ site: 'Riverbend Plant', sensor: 'SENS-RB-01', additionalSourceFields: expect.objectContaining({ 'Device ID': expect.stringMatching(/^DEV-OT-/), 'CVSS Score': expect.any(String), Component: expect.any(String), Status: expect.any(String) }) })])); const entry = confirmEntryMappings(previewEntryFromDataset(imported, { sha256: 'f'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified }), importProfiles); const canonical = createCanonicalImportBatch('defender-mock-cve-context', [entry], '2026-09-14T06:00:00.000Z').model; const projected = canonicalModelToDataset(canonical); expect(projected).not.toBeNull(); const model = defenderModelForDataset(projected!); expect(model.cves).toHaveLength(5); expect(model.cves.every(cve => cve.sites.join() === 'Riverbend Plant')).toBe(true); expect(model.cves.every(cve => cve.sensors.join() === 'SENS-RB-01')).toBe(true); expect(inventory.profileId).toBe('defender-for-iot-vulnerabilities'); expect(inventory.profileVersion).toBe('1.2.0'); expect(inventory.extraction?.candidates).toHaveLength(0); expect(inventory.sheets[0].analysis.records).toHaveLength(1); }); it('selects the versioned profile and emits one candidate per CVE with devices aggregated', 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]), summary('device-b', '10.0.0.2', 'Synthetic site', [1, 0, 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'), detail('device-b', '10.0.0.2', 'Synthetic site', 'sensor-a', '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.2.0'); expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.1.0' }); // Two devices share one CVE: the Work view must show a single aggregated finding, not one row per device. expect(imported.extraction?.candidates).toHaveLength(1); expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(3); expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Device Count')?.originalValue).toBe(2); // The affected devices are carried as a markdown table for the result popup, one row per device with its name, IP address, scanner and site. const devicesTable = imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Devices')?.decodedValue ?? ''; expect(devicesTable).toContain('| Name | IP Address | Scanner | Site |'); expect(devicesTable).toContain('| 00:11:22:33:44:55 | 10.0.0.1 |'); expect(devicesTable).toContain('| device-b | 10.0.0.2 |'); expect(imported.mapping?.candidates).toHaveLength(1); expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([2, 3]); 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.2.0', normalizationVersion: '1.1.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2' } }); expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] }); expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingOccurrences).toHaveLength(1); expect(canonical.findingOccurrences[0]).toMatchObject({ outcome: { normalizedValue: 'warning' }, technicalExecutionState: { normalizedValue: 'completed' } }); expect(createScannerUnifiedResult({ project: canonical.project, assessment: canonical.assessment, scanRun: canonical.scanRuns[0], importFile: canonical.importFiles[0], definition: canonical.findingDefinitions[0], occurrence: canonical.findingOccurrences[0], reviews: canonical.reviews })).toMatchObject({ resultType: 'vulnerability_finding', outcomeNormalized: 'warning', isIoe: false, isDeviation: true }); // The aggregated CVE carries its affected-device count as the affected asset (two devices here); the device list lives in the evidence table. expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toBe('2'); expect(canonical.findingOccurrences[0].score).toBeUndefined(); expect(canonical.evidence).toHaveLength(1); expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) }); expect(canonical.evidence[0].value).toHaveLength(3); // Both asset-summary rows stay preserved as source data alongside the single aggregated finding. expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(2); }); 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); }); });