191 lines
14 KiB
TypeScript
191 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import * as XLSX from 'xlsx';
|
|
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 { createScannerUnifiedResult } from '../../src/domain/unified-result';
|
|
import { isDeviationRecord } from '../../src/importers/status-normalization';
|
|
import { recordTitleFor } from '../../src/features/dataset-viewer/record-title';
|
|
import { datahubScanReadiness } from '../../src/features/project-import/datahub-scan-readiness';
|
|
import { commonDefenderAlertDescription } from '../../src/domain/defender-for-iot-alerts';
|
|
import { selectFindingFields } from '../../src/features/finding-reader/FindingReader';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { RichTextValue } from '../../src/components/RichTextValue/RichTextValue';
|
|
|
|
// Synthetic alert export: fictional titles, TEST-NET addresses and documentation MACs only.
|
|
const header = 'Id,Title,First Detection,Last Detection,Last Activity,Severity,Description,Acknowledged,Additional Information,Source Address,Destination Address';
|
|
const lines = [
|
|
'A-1,Synthetic Unauthorized PLC Programming,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic programming from 192.0.2.41,False,192.0.2.21 over port CIP (44818),192.0.2.41,192.0.2.21',
|
|
'A-1,Synthetic Unauthorized PLC Programming,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic programming from 192.0.2.41,False,192.0.2.22 over port CIP (44818),192.0.2.41,192.0.2.21',
|
|
'A-2,Synthetic Unauthorized PLC Programming,12/09/2026 08:00:00,12/09/2026 08:01:00,12/09/2026 08:01:00,Critical,"Synthetic programming, second source",False,,192.0.2.42,192.0.2.21',
|
|
'A-3,Synthetic Internet Connection,13/09/2026 09:00:00,13/09/2026 09:30:00,13/09/2026 09:30:00,Major,Synthetic outbound connection,False,198.51.100.7 over port HTTPS (443),00-00-5E-00-53-01,',
|
|
'A-4,Synthetic Unused Protocol,14/09/2026 07:00:00,14/09/2026 07:00:00,14/09/2026 07:05:00,Warning,Synthetic unused protocol,True,,192.0.2.50,192.0.2.51'
|
|
];
|
|
|
|
function csvFile() {
|
|
return new File([new TextEncoder().encode([header, ...lines].join('\n'))], 'alerts-synthetic.csv', { type: 'text/csv' });
|
|
}
|
|
|
|
function wrappedWorkbook(rows = [header, ...lines]) {
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows.map(line => [line])), 'Sheet1');
|
|
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
|
return new File([bytes], 'alerts-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
}
|
|
|
|
function fieldValue(candidate: { fields: Array<{ label: string; originalValue: unknown; decodedValue: string }> } | undefined, label: string) {
|
|
return candidate?.fields.find(field => field.label === label);
|
|
}
|
|
|
|
async function canonicalFor(file: File) {
|
|
const imported = await importFile(file, { profiles: importProfiles });
|
|
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
|
|
sha256: 'a'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
|
}), importProfiles);
|
|
return { imported, canonical: createCanonicalImportBatch('defender-alerts-synthetic', [entry], '2026-09-30T06:00:00.000Z').model };
|
|
}
|
|
|
|
describe('Microsoft Defender for IoT alert export', () => {
|
|
it.each([
|
|
['native CSV', csvFile],
|
|
['workbook with one CSV line per cell', () => wrappedWorkbook()]
|
|
])('imports the %s as one finding per alert type', async (_name, file) => {
|
|
const imported = await importFile(file(), { profiles: importProfiles });
|
|
|
|
expect(imported.profileId).toBe('defender-for-iot-alerts');
|
|
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-alert-source-extractor', version: '1.5.0' });
|
|
expect(imported.extraction?.diagnostics).toEqual([]);
|
|
const candidates = imported.extraction?.candidates ?? [];
|
|
expect(candidates.map(candidate => fieldValue(candidate, 'Title')?.decodedValue)).toEqual([
|
|
'Synthetic Unauthorized PLC Programming', 'Synthetic Internet Connection', 'Synthetic Unused Protocol'
|
|
]);
|
|
const programming = candidates[0];
|
|
// Two rows share Id A-1 and differ only in Additional Information: one alert, both lines kept.
|
|
expect(fieldValue(programming, 'Alert Count')?.originalValue).toBe(2);
|
|
// Alert A-1 spans two source rows, so the Alerts table holds three rows for two alerts.
|
|
expect(fieldValue(programming, 'Detail Rows')?.originalValue).toBe(3);
|
|
expect(fieldValue(programming, 'Unacknowledged Alerts')?.originalValue).toBe(2);
|
|
expect(fieldValue(programming, 'Acknowledged Alerts')).toBeUndefined();
|
|
expect(fieldValue(programming, 'Status')?.decodedValue).toBe('Unacknowledged');
|
|
// Differing descriptions: Description holds the wording shared by all alerts under its own field key.
|
|
expect(fieldValue(programming, 'Description')).toMatchObject({ key: 'common-description', decodedValue: 'Synthetic …' });
|
|
const table = fieldValue(programming, 'Alerts')?.decodedValue ?? '';
|
|
// One table row per source row: alert A-1 appears twice, each with its own Additional Information.
|
|
const tableRows = table.split('\n').slice(2);
|
|
expect(tableRows).toHaveLength(3);
|
|
expect(tableRows.filter(row => row.startsWith('| A-1 | False | 11/09/2026 10:05:12 |'))).toHaveLength(2);
|
|
expect(tableRows[0]).toMatch(/\| 192\.0\.2\.21 over port CIP \(44818\) \|$/);
|
|
expect(tableRows[1]).toMatch(/\| 192\.0\.2\.22 over port CIP \(44818\) \|$/);
|
|
expect(tableRows[2]).toContain('Synthetic programming, second source');
|
|
expect(fieldValue(programming, 'Source Observations')?.originalValue).toHaveLength(3);
|
|
|
|
// Native severity stays retained; Major maps to High and Warning to Low.
|
|
expect(fieldValue(candidates[1], 'Severity')?.decodedValue).toBe('High');
|
|
expect(fieldValue(candidates[1], 'Defender Severity')?.decodedValue).toBe('Major');
|
|
// A single alert keeps its original description.
|
|
expect(fieldValue(candidates[1], 'Description')).toMatchObject({ key: 'description', decodedValue: 'Synthetic outbound connection' });
|
|
expect(fieldValue(candidates[2], 'Severity')?.decodedValue).toBe('Low');
|
|
expect(fieldValue(candidates[2], 'Status')?.decodedValue).toBe('Acknowledged');
|
|
});
|
|
|
|
it('stores the wrapped workbook findings so Data Hub lists the scan as viewable', async () => {
|
|
// Regression 2026-09-30: the single unrecognized text column was not a selected table, so every finding was dropped.
|
|
const { canonical } = await canonicalFor(wrappedWorkbook());
|
|
expect(canonical.findingOccurrences).toHaveLength(3);
|
|
expect(datahubScanReadiness([canonical], canonical.scanRuns[0].id, 'defender-for-iot-alerts')).toBe('recognized');
|
|
});
|
|
|
|
it('keeps only the wording shared by every description and marks differing parts', () => {
|
|
expect(commonDefenderAlertDescription([
|
|
'A Broadcast connection not allowed by policy has been detected.Source IP: 192.0.2.189, Destination IP: 192.0.2.188, Destination port: 17',
|
|
'A Broadcast connection not allowed by policy has been detected.Source IP: 192.0.2.192, Destination IP: 192.0.2.193, Destination port: Netbios Name Service (137)',
|
|
'A Multicast connection not allowed by policy has been detected.Source IP: 192.0.2.204, Destination IP: 192.0.2.194, Destination port: Web Service Discovery (3702)'
|
|
])).toBe('A … connection not allowed by policy has been detected.Source IP: … Destination IP: … Destination port: …');
|
|
// One distinct text keeps the original; nothing shared yields no derived text.
|
|
expect(commonDefenderAlertDescription(['Same text', 'Same text'])).toBeUndefined();
|
|
expect(commonDefenderAlertDescription(['alpha beta', 'gamma delta'])).toBeUndefined();
|
|
});
|
|
|
|
it('renders underscores inside alert descriptions literally in the Results table', async () => {
|
|
// Regression 2026-09-30: `[IP_189], Destination IP: [IP_` was rendered as italics and lost its underscores.
|
|
const description = 'Synthetic broadcast.Source IP: [IP_189], Destination IP: [IP_188], Destination port: [PORT_17]';
|
|
const imported = await importFile(new File([new TextEncoder().encode([header,
|
|
`A-7,Synthetic Broadcast,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Major,"${description}",False,,192.0.2.1,192.0.2.255`
|
|
].join('\n'))], 'alerts-underscore.csv', { type: 'text/csv' }), { profiles: importProfiles });
|
|
const table = fieldValue(imported.extraction?.candidates[0], 'Alerts')!.decodedValue;
|
|
const container = document.createElement('div');
|
|
container.innerHTML = renderToStaticMarkup(createElement(RichTextValue, { value: table }));
|
|
expect(container.querySelector('em')).toBeNull();
|
|
expect([...container.querySelectorAll('td')].some(cell => cell.textContent === description)).toBe(true);
|
|
});
|
|
|
|
it('keeps the original single-cell line as provenance for a wrapped workbook', async () => {
|
|
const imported = await importFile(wrappedWorkbook(), { profiles: importProfiles });
|
|
expect(imported.recognition?.source).toMatchObject({ selected: { toolId: 'defender-for-iot-alerts' }, requiresConfirmation: false });
|
|
const observations = fieldValue(imported.extraction?.candidates[1], 'Source Observations')?.originalValue as Array<Record<string, unknown>>;
|
|
expect(observations[0]).toMatchObject({ rawLine: lines[3], fields: { Id: 'A-3', Severity: 'Major', 'Destination Address': '' } });
|
|
});
|
|
|
|
it('counts unacknowledged alert types as deviations and acknowledged ones as visible non-deviations', async () => {
|
|
const { canonical } = await canonicalFor(csvFile());
|
|
const results = canonical.findingOccurrences.map(occurrence => createScannerUnifiedResult({
|
|
project: canonical.project, assessment: canonical.assessment, scanRun: canonical.scanRuns[0], importFile: canonical.importFiles[0],
|
|
definition: canonical.findingDefinitions.find(definition => definition.id === occurrence.definitionId)!, occurrence, reviews: canonical.reviews
|
|
}));
|
|
const byRule = new Map(canonical.findingOccurrences.map((occurrence, index) => [occurrence.sourceIdentity.originalRuleId, { occurrence, result: results[index] }]));
|
|
|
|
expect(canonical.findingDefinitions).toHaveLength(3);
|
|
expect(byRule.get('Synthetic Unauthorized PLC Programming')).toMatchObject({
|
|
occurrence: { severity: { normalizedValue: 'critical' }, outcome: { normalizedValue: 'warning' } },
|
|
result: { isDeviation: true }
|
|
});
|
|
expect(byRule.get('Synthetic Internet Connection')?.occurrence.severity.normalizedValue).toBe('high');
|
|
expect(byRule.get('Synthetic Unused Protocol')).toMatchObject({
|
|
occurrence: { severity: { normalizedValue: 'low' }, outcome: { normalizedValue: 'informational' } },
|
|
result: { isDeviation: false }
|
|
});
|
|
expect(canonical.importFiles[0]).toMatchObject({ processedWith: { profileId: 'defender-for-iot-alerts', compatibilityKey: 'defender-for-iot-alerts-v1' } });
|
|
|
|
// Work reads the same deviation from the projected record and names it by the alert title.
|
|
const projected = canonicalModelToDataset(canonical)!;
|
|
const sheet = projected.sheets.find(item => item.toolId === 'defender-for-iot-alerts' && item.analysis.records.length)!;
|
|
const deviations = sheet.analysis.records.filter(record => isDeviationRecord(record, sheet.toolId));
|
|
expect(deviations.map(record => recordTitleFor(record, '', sheet.toolId)).sort()).toEqual([
|
|
'Synthetic Internet Connection', 'Synthetic Unauthorized PLC Programming'
|
|
]);
|
|
// The Finding Reader leads with the shared wording as Description.
|
|
const programming = sheet.analysis.records.find(record => recordTitleFor(record, '', sheet.toolId) === 'Synthetic Unauthorized PLC Programming')!;
|
|
expect(selectFindingFields(programming, 'Synthetic Unauthorized PLC Programming', true, sheet.toolId).summary[0])
|
|
.toMatchObject({ label: 'Description', value: 'Synthetic …' });
|
|
});
|
|
|
|
it('reports conflicting rows, unreadable wrapped lines and missing titles instead of dropping them silently', async () => {
|
|
const imported = await importFile(wrappedWorkbook([
|
|
header,
|
|
lines[0],
|
|
lines[1].replace('Critical', 'Major'),
|
|
'A-9,Synthetic broken line,"unterminated',
|
|
'A-10,,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic,False,,192.0.2.1,192.0.2.2'
|
|
]), { profiles: importProfiles });
|
|
|
|
// A failed extraction is never a silent success: the export is recognized but needs confirmation.
|
|
expect(imported.recognition?.source).toMatchObject({ selected: { toolId: 'defender-for-iot-alerts' }, requiresConfirmation: true });
|
|
expect(imported.extraction?.diagnostics.map(item => item.code).sort()).toEqual([
|
|
'defender-alert-rows-conflict', 'defender-alert-title-missing', 'defender-alert-wrapped-row-unreadable'
|
|
]);
|
|
expect(imported.extraction?.processing.status).toBe('failed');
|
|
// Both conflicting rows remain evidence of the single alert.
|
|
expect(fieldValue(imported.extraction?.candidates[0], 'Source Observations')?.originalValue).toHaveLength(2);
|
|
});
|
|
|
|
it('does not claim a vulnerability export or a generic table', async () => {
|
|
const imported = await importFile(new File([new TextEncoder().encode('Id,Title,Severity\n1,Synthetic,High')], 'other.csv', { type: 'text/csv' }), { profiles: importProfiles });
|
|
expect(imported.profileId).not.toBe('defender-for-iot-alerts');
|
|
});
|
|
});
|