diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index d345e59..3444510 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3652,6 +3652,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. | Datum | Änderung | +| 2026-10-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 13A: Aus `33d2019` nur die Dokumentation uebernommen. `docs/CANONICAL_MODEL.md` beschreibt `vulnerability_finding` als additive ASSESSOR-Erweiterung des externen `result_type`-Katalogs; der Kommentar an `CanonicalResultType` verweist darauf. Nicht uebernommen: die Backfill-Migration (frueher 023) samt Katalogeintrag und Schema-Assertions, weil die neu angelegte lokale Datenbank keine Defender-Occurrences enthaelt, sowie auf Nutzerentscheidung der lesende Outcome-Fallback in `unified-result.ts` mit seinem Test; neue Importe erhalten `warning` bereits ueber `missingStatusOutcome`. Keine Verhaltens-, API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/13A: `fe71246` uebernommen; `e048295` (NVD-CVE-Anreicherung) auf Nutzerentscheidung vorerst ausgelassen. Der Result-Contract kennt den Typ `vulnerability_finding`; Defender-Findings erhalten ihn. Ein Profil kann `missingStatusOutcome` deklarieren, Defender setzt `warning`, weil jede CVE-Zeile selbst der Befund ist. Das Defender-Profil steigt auf 1.1.0 mit Kompatibilitaetsschluessel `-v2` (semantic-breaking, Reimport erforderlich; die lokale Datenbank enthaelt keine Defender-Importe), 1.0.0 ist nicht mehr aktuell. Der Work-Filter `Deviation` aller Scanner nutzt statt der Regex-Liste `UNIVERSAL_DEVIATION_PATTERNS` nun `isDeviationRecord` und damit das kanonische Result-DB-Praedikat; die GPO-Statuslogik, der Statuscache und die uebrigen Statusfilter der Basis bleiben unveraendert davor. Alle bestehenden Work-, Filter- und Scannertests bestehen unveraendert; eine vollstaendige Gleichwertigkeit fuer jeden realen Statuswert ist damit nicht bewiesen. Migration `027_vulnerability_finding_result_type.sql` (frueher 022) erweitert nur den Check-Constraint und schreibt keine Occurrences um; ohne die ausgelassene CVE-Tabelle. Vor der Anwendung wurde das verifizierte Backup `backups/assessor-pre-027-20261002-123608.dump` erstellt. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | 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 768802b..e891412 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 8d7e3db..c1cc2be 100644 --- a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx +++ b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx @@ -69,7 +69,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 fa3c09a..591b2d9 100644 --- a/src/importers/defender-for-iot-source-extractor.ts +++ b/src/importers/defender-for-iot-source-extractor.ts @@ -100,13 +100,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 b69763b..973eca0 100644 --- a/src/profiles/defender-for-iot/defender-for-iot-definition.ts +++ b/src/profiles/defender-for-iot/defender-for-iot-definition.ts @@ -20,18 +20,21 @@ export const defenderForIotDefinition: ToolProfileDefinition = { reason: 'The Defender for IoT vulnerability export is a point-in-time CVE inventory without a stable scan scope or observation time for timeslot comparison.' }, 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 f9a8807..ed4426d 100644 --- a/tests/features/datahub-processing-readiness.test.ts +++ b/tests/features/datahub-processing-readiness.test.ts @@ -125,7 +125,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 6babc00..848c5cb 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' }