191 lines
12 KiB
TypeScript
191 lines
12 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 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.0.0');
|
|
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.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 scanner and site.
|
|
const devicesTable = imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Devices')?.decodedValue ?? '';
|
|
expect(devicesTable).toContain('| Affected Device | Scanner | Site |');
|
|
expect(devicesTable).toContain('00:11:22:33:44:55');
|
|
expect(devicesTable).toContain('device-b');
|
|
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.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);
|
|
// 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);
|
|
});
|
|
});
|