diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index e4b9ef5..ccb22c2 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2,6 +2,8 @@ Status: lebendes Projektdokument +Defender-Profil 1.2.0 2026-09-11 (Phase 6/13A): Das Profil verarbeitet die synthetischen `Mock-Defender`-Dateien `cves.csv` und `device_inventory.csv` ueber den generischen CSV-Adapter und verbindet sie ausschliesslich ueber `Device ID`. Site und Sensor stammen nur aus vorhandenen Feldern; CVSS, Component und Mock-Workflowstatus bleiben Evidenz und werden nicht als nativer Gesamtscore oder Pass/Fail interpretiert. Source Extractor 1.1.0 und Processing-Manifest 1.2.0 sind append-only registriert; Profil 1.1.0 bleibt lesekompatibel, wird aber als aktualisierbar markiert. + Defender-for-IoT-Drilldowns 2026-09-11 (Phase 7): CVE-, Device- und Site-Details erscheinen inline unter der ausgewaehlten Zeile mit stabilen, zugaenglichen Auf-/Zuklappzielen und gemeinsamer gelber Auswahlmarkierung. CVE-Evidenz zeigt betroffene Geraete flach; redundante Beschreibungs-, Observation- und Sensorangaben bleiben in den jeweils passenden Detailkontexten statt in der Uebersicht. Die Detailflaeche verwendet die gemeinsame Tile-Oberflaeche. Der NVD-Adapter uebernimmt `baseSeverity` aus den CVSS-Metriken. Reine Darstellungs-/Adapterkorrektur ohne Persistenz- oder Reimportaenderung. Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit CVE-, Device- und Site-Ansichten. Sie verwendet denselben themenabhaengigen Client-Info-Canvas, dieselbe Kopfzeile, denselben Panel-Inset sowie die flache Kachel-, Flaechen-, Typografie-, Auswahl- und Responsive-Grammatik wie Overview. Scannerprojektion, Finding-Semantik, Scoreberechnung, Import- und Persistenzvertraege bleiben unveraendert; kein Reimport erforderlich. diff --git a/docs/TOOL_PROFILES.md b/docs/TOOL_PROFILES.md index 83a3305..8bd8e7c 100644 --- a/docs/TOOL_PROFILES.md +++ b/docs/TOOL_PROFILES.md @@ -32,7 +32,7 @@ Das Schema steht in `src/domain/tool-profile-definition.ts`. Die gemeinsame Lauf | PingCastle | 1.0.0 | Healthcheck, Risk Scores und Regelkennungen | | CIS-CAT Pro | 1.0.0 | Assessment Results, Recommendation und Pass/Fail | | Maester | 1.0.0 | flache TestResults-Exporte, MT-IDs und ResultDetail | -| Microsoft Defender for IoT | 1.0.0 | Asset-Summary, CVE-Details, Device-Identitaet und Sensorprovenienz | +| Microsoft Defender for IoT | 1.2.0 | Asset-Summary, Mock-CVE-CSV, Device-Inventar, Device-ID-Verknuepfung und Sensorprovenienz | Die Definitionen liegen jeweils im Verzeichnis des Werkzeugs. `profile-definition-catalog.ts` stellt sie der Erkennungsengine bereit; `profile-catalog.ts` stellt die daraus erzeugten Laufzeitprofile für den Import und die Oberfläche bereit. @@ -65,7 +65,7 @@ Ein neues Werkzeug benötigt: Formatparser, Erkennungsalgorithmus und React-Komponenten müssen dafür nicht geändert werden. -Microsoft Defender for IoT verwendet weiterhin den generischen Excel-/CSV-Formatadapter. Ein kleiner Source Extractor verbindet Summary- und Detailtabellen, erzeugt genau ein Finding-Vorkommen je normalisiertem Device und CVE und bewahrt wiederholte Sensorzeilen als Evidenz. Summary-Werte ohne Detail-CVE bleiben separate Aggregate und werden nicht in Findings umgewandelt. Die fachlichen CVE-, Device- und Site-Projektionen liegen ausserhalb der React-Komponente in einem deterministischen Domain-Modell. +Microsoft Defender for IoT verwendet weiterhin den generischen Excel-/CSV-Formatadapter. Ein kleiner Source Extractor verarbeitet sowohl das bisherige Summary-/Detailtabellenpaar als auch die synthetischen `Mock-Defender`-CSV-Dateien `cves.csv` und `device_inventory.csv`. Die Mock-CVE-Datei wiederholt Site und Sensor als ausdruecklich simulierte Fixture-Felder, damit ein Einzelimport die CVE-Ansicht vollstaendig speist; bei einem Mehrdateiimport verbindet das Domain-Modell CVE- und Inventarzeilen weiterhin ausschliesslich ueber die explizite `Device ID`. Fehlende Kontext- oder Beschreibungswerte werden nicht erfunden. Der Extractor erzeugt genau ein Finding-Vorkommen je Device und CVE, aggregiert diese je CVE und bewahrt CVSS Score, Component, Status sowie wiederholte Sensorzeilen als Evidenz. Summary-Werte ohne Detail-CVE bleiben separate Aggregate und werden nicht in Findings umgewandelt. Die fachlichen CVE-, Device- und Site-Projektionen liegen ausserhalb der React-Komponente in einem deterministischen Domain-Modell. ## Darstellung von Textfeldern diff --git a/src/domain/defender-for-iot.ts b/src/domain/defender-for-iot.ts index 8deb617..1a318c7 100644 --- a/src/domain/defender-for-iot.ts +++ b/src/domain/defender-for-iot.ts @@ -4,7 +4,8 @@ export const DEFENDER_FOR_IOT_PROFILE_ID = 'defender-for-iot-vulnerabilities'; export type DefenderSeverity = 'critical' | 'high' | 'medium' | 'low'; export type DefenderDetailState = 'complete' | 'aggregate-only' | 'detail-only' | 'mismatch' | 'zero' | 'unavailable'; -export type DefenderIdentityMethod = 'mac' | 'ip' | 'name' | 'unresolved'; +export type DefenderIdentityMethod = 'device-id' | 'mac' | 'ip' | 'name' | 'unresolved'; +export type DefenderTableRole = 'summary' | 'detail' | 'inventory'; export interface DefenderSourceRow { tableIndex: number; @@ -108,7 +109,9 @@ export interface DefenderVulnerabilityModel { available: boolean; summaryTableCount: number; detailTableCount: number; + inventoryTableCount: number; summaryRowCount: number; + inventoryRowCount: number; devices: DefenderDevice[]; cves: DefenderCve[]; occurrences: DefenderOccurrence[]; @@ -121,6 +124,19 @@ export interface DefenderVulnerabilityModel { const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low']; const summaryHeaders = ['name', 'ip', 'site', 'sensor', 'totalcves', 'critical', 'high', 'medium', 'low']; const detailHeaders = ['name', 'ip', 'site', 'sensor', 'cve', 'severity', 'beschreibung']; +const mockDetailHeaders = ['deviceid', 'devicename', 'ipaddress', 'cve', 'cvssscore', 'severity', 'component', 'status']; +const inventoryHeaders = ['deviceid', 'name', 'ipv4address', 'macaddress']; +const canonicalObservationHeaders = new Set(['name', 'devicename', 'ip', 'ipaddress', 'ipv4address', 'site', 'sensor', 'cve', 'severity', 'beschreibung', 'description']); +const headerAliases: Record = { + name: ['name', 'devicename'], + ip: ['ip', 'ipaddress', 'ipv4address'], + deviceid: ['deviceid'], + site: ['site'], + sensor: ['sensor'], + cve: ['cve'], + severity: ['severity'], + beschreibung: ['beschreibung', 'description'] +}; function text(value: JsonValue | undefined): string { if (value === null || value === undefined) return ''; @@ -139,7 +155,8 @@ function normalizedText(value: string): string { function value(row: DefenderSourceRow, header: string): JsonValue | undefined { const target = normalizedHeader(header); - const match = Object.keys(row.fields).find(key => normalizedHeader(key) === target); + const aliases = headerAliases[target] ?? [target]; + const match = Object.keys(row.fields).find(key => aliases.includes(normalizedHeader(key))); return match === undefined ? undefined : row.fields[match]; } @@ -169,10 +186,12 @@ function severity(value: string): DefenderSeverity | 'unknown' { return severities.find(item => item === normalized) ?? 'unknown'; } -export function defenderTableRole(headersInput: string[]): 'summary' | 'detail' | undefined { +export function defenderTableRole(headersInput: string[]): DefenderTableRole | undefined { const headers = new Set(headersInput.map(normalizedHeader)); if (detailHeaders.every(header => headers.has(header))) return 'detail'; + if (mockDetailHeaders.every(header => headers.has(header))) return 'detail'; if (summaryHeaders.every(header => headers.has(header))) return 'summary'; + if (inventoryHeaders.every(header => headers.has(header)) && !headers.has('cve')) return 'inventory'; return undefined; } @@ -190,6 +209,8 @@ function identitySeed(row: DefenderSourceRow): IdentitySeed { const displayName = text(value(row, 'Name')); const ip = text(value(row, 'IP')); const site = text(value(row, 'Site')); + const deviceId = text(value(row, 'Device ID')); + if (deviceId) return { method: 'device-id', base: normalizedText(deviceId), site, displayName, ip, provenance: 'source-native Device ID', confidence: 'high' }; const macValue = [displayName, ip].find(isMac); if (macValue) return { method: 'mac', base: normalizedText(macValue).replace(/[:-]/g, ''), site, displayName, ip, provenance: macValue === displayName ? 'valid MAC from Name' : 'valid MAC from IP', confidence: 'high' }; const ipValue = [ip, displayName].find(item => isIpv4(item) || isIpv6(item)); @@ -247,9 +268,11 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]): const diagnostics: DefenderDiagnostic[] = []; const summaryTables = tables.filter(table => defenderTableRole(table.headers) === 'summary'); const detailTables = tables.filter(table => defenderTableRole(table.headers) === 'detail'); + const inventoryTables = tables.filter(table => defenderTableRole(table.headers) === 'inventory'); const summaryRows = summaryTables.flatMap(table => table.rows); const detailRows = detailTables.flatMap(table => table.rows); - const allRows = [...summaryRows, ...detailRows]; + const inventoryRows = inventoryTables.flatMap(table => table.rows); + const allRows = [...summaryRows, ...detailRows, ...inventoryRows]; const identities = identityKeys(allRows, diagnostics); for (const row of summaryRows) { @@ -302,7 +325,9 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]): const severitySource = text(value(row, 'Severity')); const additionalSourceFields: Record = {}; for (const [label, fieldValue] of Object.entries(row.fields)) { - if (normalizedHeader(label) === 'null' && text(fieldValue)) additionalSourceFields[label] = fieldValue; + if (!canonicalObservationHeaders.has(normalizedHeader(label)) && (normalizedHeader(label) !== 'null' || text(fieldValue))) { + additionalSourceFields[label] = fieldValue; + } } return [{ id: `observation-${row.tableIndex}-${row.rowIndex}`, @@ -386,27 +411,35 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]): const occurrenceByDevice = new Map(); for (const occurrence of occurrences) occurrenceByDevice.set(occurrence.deviceId, [...(occurrenceByDevice.get(occurrence.deviceId) ?? []), occurrence]); + const inventoryByDevice = new Map(); + for (const row of inventoryRows) { + const id = identities.get(row)!.id; + inventoryByDevice.set(id, [...(inventoryByDevice.get(id) ?? []), row]); + } const severitiesWithDetail = new Set(occurrences.map(occurrence => occurrence.severity).filter((item): item is DefenderSeverity => item !== 'unknown')); - const deviceIds = new Set([...summaryByDevice.keys(), ...occurrenceByDevice.keys()]); + const deviceIds = new Set([...summaryByDevice.keys(), ...occurrenceByDevice.keys(), ...inventoryByDevice.keys()]); const devices: DefenderDevice[] = [...deviceIds].map(id => { const summaryGroup = summaryByDevice.get(id) ?? []; const detailGroup = occurrenceByDevice.get(id) ?? []; - const representativeRow = summaryGroup[0] ?? detailRows.find(row => identities.get(row)?.id === id)!; + const inventoryGroup = inventoryByDevice.get(id) ?? []; + const deviceDetailRows = detailRows.filter(row => identities.get(row)?.id === id); + const representativeRow = summaryGroup[0] ?? inventoryGroup[0] ?? deviceDetailRows[0]; const identity = identities.get(representativeRow)!; const summaryRow = summaryGroup[0]; const detailCounts = emptyCounts(); for (const occurrence of detailGroup) if (occurrence.severity !== 'unknown') detailCounts[occurrence.severity] += 1; const summaryCounts = Object.fromEntries(severities.map(item => [item, summaryRow ? numeric(value(summaryRow, item)) : undefined])) as Record; const measures = Object.fromEntries(severities.map(item => [item, detailState(summaryCounts[item], detailCounts[item], severitiesWithDetail.has(item))])) as Record; - const sourceRows = [...summaryGroup, ...detailRows.filter(row => identities.get(row)?.id === id)]; + const sourceRows = [...summaryGroup, ...inventoryGroup, ...deviceDetailRows]; + const firstValue = (header: string) => sourceRows.map(row => text(value(row, header))).find(Boolean) ?? ''; return { id, identityMethod: identity.method, identityConfidence: identity.confidence, identityProvenance: identity.provenance, - displayName: text(value(representativeRow, 'Name')), - ip: text(value(representativeRow, 'IP')), - site: text(value(representativeRow, 'Site')), + displayName: firstValue('Name'), + ip: firstValue('IP'), + site: firstValue('Site'), sensors: [...new Set(sourceRows.map(row => text(value(row, 'Sensor'))).filter(Boolean))].sort(), totalCves: summaryRow ? numeric(value(summaryRow, 'TotalCVEs')) : undefined, severity: measures, @@ -454,10 +487,12 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]): }).sort((left, right) => left.name.localeCompare(right.name)); return { - available: summaryTables.length > 0 || detailTables.length > 0, + available: summaryTables.length > 0 || detailTables.length > 0 || inventoryTables.length > 0, summaryTableCount: summaryTables.length, detailTableCount: detailTables.length, + inventoryTableCount: inventoryTables.length, summaryRowCount: summaryRows.length, + inventoryRowCount: inventoryRows.length, devices, cves, occurrences, diff --git a/src/domain/processing-compatibility.ts b/src/domain/processing-compatibility.ts index 9abdab7..2f25020 100644 --- a/src/domain/processing-compatibility.ts +++ b/src/domain/processing-compatibility.ts @@ -25,7 +25,8 @@ export interface ProcessingVersionManifest extends ToolProcessingContract { export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] = [ { profileId: 'generic', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'generic-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true }, { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: false }, - { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'], current: true }, + { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'], current: false }, + { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.2.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['recognition', 'extraction', 'finding-identity', 'evidence'], current: true }, { profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'purple-knight-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, { profileId: 'purple-knight', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, { profileId: 'purple-knight', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false }, diff --git a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx index a721bca..44b09ac 100644 --- a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx +++ b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx @@ -67,7 +67,7 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
Raw sensor observations
Aggregate-only vulnerabilities
-

{model.summaryRowCount.toLocaleString('en-US')} source asset rows{model.diagnostics.length.toLocaleString('en-US')} diagnostics

+

{(model.summaryRowCount + model.inventoryRowCount).toLocaleString('en-US')} source asset rows{model.diagnostics.length.toLocaleString('en-US')} diagnostics

diff --git a/src/importers/defender-for-iot-source-extractor.ts b/src/importers/defender-for-iot-source-extractor.ts index b14bd66..30b18f5 100644 --- a/src/importers/defender-for-iot-source-extractor.ts +++ b/src/importers/defender-for-iot-source-extractor.ts @@ -101,13 +101,14 @@ function cveCandidate( export const defenderForIotSourceExtractor: SourceExtractor = { id: 'defender-for-iot-vulnerability-source-extractor', - version: '1.0.0', + version: '1.1.0', detect(document) { const roles = document.tables.map(table => defenderTableRole(headerFor(table))).filter(Boolean); if (!roles.length) return { score: 0, evidence: [] }; const both = roles.includes('summary') && roles.includes('detail'); return { score: both ? 1 : 0.92, + toolId: 'defender-for-iot-vulnerabilities', evidence: [both ? 'A Defender for IoT asset summary and detailed vulnerability table were found.' : `A Defender for IoT ${roles[0]} table was found.`] diff --git a/src/profiles/defender-for-iot/defender-for-iot-definition.ts b/src/profiles/defender-for-iot/defender-for-iot-definition.ts index 7b71219..5ba43fe 100644 --- a/src/profiles/defender-for-iot/defender-for-iot-definition.ts +++ b/src/profiles/defender-for-iot/defender-for-iot-definition.ts @@ -5,18 +5,21 @@ export const defenderForIotDefinition: ToolProfileDefinition = { schemaVersion: 1, id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', - version: '1.1.0', + version: '1.2.0', processing: { mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', - reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'] + reimportRequired: true, affectedDimensions: ['recognition', 'extraction', 'finding-identity', 'evidence'] }, sourceSignals: [ { scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' }, { scope: 'header', pattern: /^CVE$/i, contribution: 0.38, message: 'The detail table exposes CVE identifiers.' }, { scope: 'header', pattern: /^Sensor$/i, contribution: 0.14, message: 'The source records a reporting sensor.' }, { scope: 'header', pattern: /^Beschreibung$/i, contribution: 0.18, message: 'The detail table exposes the Defender CVE description field.' }, - { scope: 'header', pattern: /^Critical$/i, contribution: 0.1, message: 'The asset summary exposes Critical counts.' } + { scope: 'header', pattern: /^Critical$/i, contribution: 0.1, message: 'The asset summary exposes Critical counts.' }, + { scope: 'header', pattern: /^Device ID$/i, contribution: 0.24, message: 'The source exposes the Defender device identifier.' }, + { scope: 'header', pattern: /^CVSS Score$/i, contribution: 0.2, message: 'The CVE export exposes a CVSS score.' }, + { scope: 'header', pattern: /^Component$/i, contribution: 0.1, message: 'The CVE export identifies the affected component.' } ], tableRules: { preferredNames: [ diff --git a/tests/domain/processing-compatibility.test.ts b/tests/domain/processing-compatibility.test.ts index c19f576..4046114 100644 --- a/tests/domain/processing-compatibility.test.ts +++ b/tests/domain/processing-compatibility.test.ts @@ -43,12 +43,14 @@ describe('processing compatibility contracts', () => { it('uses the recorded profile when a caller supplies a different detected tool id', () => { const legacy = file('defender-old', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0'); - const current = file('defender-current', 'defender-for-iot-vulnerabilities', '1.1.0', '1.0.0'); + const previous = file('defender-previous', 'defender-for-iot-vulnerabilities', '1.1.0', '1.0.0'); + const current = file('defender-current', 'defender-for-iot-vulnerabilities', '1.2.0', '1.0.0'); current.processedWith.normalizationVersion = '1.1.0'; current.processedWith.presentationVersion = '1.0.0'; current.processedWith.compatibilityKey = 'defender-for-iot-vulnerabilities-v2'; expect(processingReadiness('microsoft-defender-for-iot', legacy.processedWith)).toBe('incompatible'); + expect(processingReadiness('microsoft-defender-for-iot', previous.processedWith)).toBe('reimport-recommended'); expect(processingReadiness('microsoft-defender-for-iot', current.processedWith)).toBe('current'); expect(resolvedCompatibilityKey('microsoft-defender-for-iot', current.processedWith)) .toBe('defender-for-iot-vulnerabilities-v2'); diff --git a/tests/features/datahub-processing-readiness.test.ts b/tests/features/datahub-processing-readiness.test.ts index 8136f05..aca062b 100644 --- a/tests/features/datahub-processing-readiness.test.ts +++ b/tests/features/datahub-processing-readiness.test.ts @@ -126,7 +126,7 @@ describe('Datahub client processing readiness', () => { it('assesses the stored profile contract independently from the detected tool identity', () => { const defender = withDetectedTool( - file('run-defender', 'defender-for-iot-vulnerabilities', '1.1.0', 'defender-for-iot-vulnerabilities-v2'), + file('run-defender', 'defender-for-iot-vulnerabilities', '1.2.0', 'defender-for-iot-vulnerabilities-v2'), 'microsoft-defender-for-iot' ); defender.processedWith.mappingVersion = '1.0.0'; diff --git a/tests/importers/defender-for-iot.test.ts b/tests/importers/defender-for-iot.test.ts index 58c9619..ebd601e 100644 --- a/tests/importers/defender-for-iot.test.ts +++ b/tests/importers/defender-for-iot.test.ts @@ -8,6 +8,7 @@ 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'; @@ -115,6 +116,71 @@ describe('Microsoft Defender for IoT vulnerability model', () => { 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([ @@ -130,8 +196,8 @@ describe('Microsoft Defender for IoT vulnerability model', () => { 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.1.0'); - expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' }); + 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); @@ -152,7 +218,7 @@ describe('Microsoft Defender for IoT vulnerability model', () => { detectedTool: { id: 'defender-for-iot-vulnerabilities' }, processedWith: { profileId: 'defender-for-iot-vulnerabilities', - profileVersion: '1.1.0', + profileVersion: '1.2.0', normalizationVersion: '1.1.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2' }