From 57e57d1abb86a3eaf66efb7fc6a2d16e648c252a Mon Sep 17 00:00:00 2001 From: Binh Date: Thu, 10 Sep 2026 12:22:41 +0200 Subject: [PATCH] feat(defender-for-iot): aggregate affected devices in findings and update result display --- docs/IMPLEMENTATION_PLAN.md | 4 ++ src/features/finding-reader/FindingReader.tsx | 9 +++ .../results-browser/ResultsBrowser.tsx | 8 ++- .../defender-for-iot-source-extractor.ts | 64 ++++++++++++------- .../defender-for-iot-definition.ts | 11 +--- tests/importers/defender-for-iot.test.ts | 28 +++++--- 6 files changed, 85 insertions(+), 39 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 1a63852..0000834 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2580,6 +2580,10 @@ 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-09-10 | Defender-Betroffenheitszahl von der Rolle `score` auf `asset` umgestellt: In ASSESSOR ist `score` die Risikomagnitude, waehrend die Anzahl betroffener Devices eine Verbreitungskennzahl ist. Das aggregierte CVE traegt die Anzahl daher als `affectedAsset`; der Results-Browser zeigt sie unter der bestehenden Betroffenheitszeile, und `score` bleibt fuer echte Risikoscores frei. Kein Vergleichsschluessel betroffen (weiter `rule-id`). | +| 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl statt eines leeren `Affected Object`. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. | +| 2026-09-10 | Defender-Ergebnisfenster auf eine Device-Tabelle umgestellt: `Affected Devices` zeigt Device, Scanner und Site; maschinenlesbare `Source Observations` bleiben als Evidenz erhalten und werden in der Lesespalte ausgeblendet. Keine SQL- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. | +| 2026-09-10 | Phase-13A-Defender-Projektion auf CVE-Aggregation umgestellt: Der Source Extractor emittiert genau ein Finding je CVE; betroffene Devices werden zusammengefasst, alle Sensorbeobachtungen bleiben erhalten, und der Vergleichsschluessel ist `rule-id`. Keine SQL- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. | | 2026-09-10 | Phasen 7/13A fuer gemischte Defender-Timeslots korrigiert: Work bestimmt den Titel jedes Datensatzes anhand seiner Scannerprovenienz und des deklarativen Titel-Headers, sodass Defender-Vorkommen im gemeinsamen All-Records-Blatt die CVE statt Device-Name oder IP zeigen. Client Info behaelt bei mehreren Scannern die allgemeine Uebersicht und bietet Defender zusaetzlich als eigenen Tab mit den vorhandenen CVE-, Device- und Site-Sichten an. 19 fokussierte Dashboard-, Titel- und Graphregressionen erfolgreich. Keine API-, SQL-, Profil- oder Persistenzmigration; Phasen 7 und 13A bleiben abgeschlossen. | | 2026-09-10 | Phase-13A-Verarbeitungsvertrag fuer Defender-Importe korrigiert: Die Kompatibilitaetspruefung verwendet nun den tatsaechlich persistierten `processedWith.profileId` statt einer eventuell abweichenden erkannten Werkzeug-ID. Dadurch werden registrierte Defender-Profile 1.0.0 als aktuell bewertet; unbekannte Profil-/Versionspaare bleiben fail closed. Die Korrektur ist read-only fuer vorhandene Importdaten und benoetigt keinen Reimport oder Datenbankeingriff. 17 fokussierte Import-/Kompatibilitaetstests sowie der Gesamtlauf mit 766 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich; Phase 13A bleibt abgeschlossen. | | 2026-09-10 | Microsoft Defender for IoT vulnerability imports and views umgesetzt: Profil und Source Extractor 1.0.0 erweitern die generische Excel-/CSV-Pipeline; Device-Identitaeten, CVE-Definitionen, Device-mal-CVE-Vorkommen, rohe Sensorbeobachtungen und Aggregate-only-Zahlen bleiben getrennt. Client Info bietet CVE-, Device- und Site-Sichten mit Evidenz- und Reconciliation-Drill-down. Summary-only-Quellen bleiben ohne erfundene Findings viewable. 33 fokussierte synthetische Tests sowie 764 Standardtests, 8 uebersprungene optionale PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzmigration; Phasen 6, 7 und 13A bleiben abgeschlossen. | diff --git a/src/features/finding-reader/FindingReader.tsx b/src/features/finding-reader/FindingReader.tsx index 7b09a70..0224149 100644 --- a/src/features/finding-reader/FindingReader.tsx +++ b/src/features/finding-reader/FindingReader.tsx @@ -191,6 +191,15 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr .filter(field => isPingCastleLinkedSectionField(field.label)) .forEach(field => hiddenFieldIndexes.add(field.index)); } + // Die Defender-Rohbeobachtungen bleiben als maschinenlesbare Provenienz im + // Datensatz (die Defender-Sonderansicht rekonstruiert daraus ihr Modell), + // gehoeren aber nicht in die Lesespalte: der fachliche Beleg steht als + // "Affected Devices"-Tabelle im Ergebnisfenster. + if (toolId === 'defender-for-iot-vulnerabilities') { + record.fields + .filter(field => normalizeLabel(field.label) === 'source observations') + .forEach(field => hiddenFieldIndexes.add(field.index)); + } if (hasStructuredResults) { record.fields .filter(field => isRedundantDetailField(field.label)) diff --git a/src/features/results-browser/ResultsBrowser.tsx b/src/features/results-browser/ResultsBrowser.tsx index 22e9517..fc49cce 100644 --- a/src/features/results-browser/ResultsBrowser.tsx +++ b/src/features/results-browser/ResultsBrowser.tsx @@ -224,7 +224,13 @@ export function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultPro
  • Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}
  • : <>
  • Rule ID: {item.sourceRuleId ?? '–'}
  • -
  • Affected Object: {item.affectedAsset ?? '–'}
  • + {/* Defender-CVEs aggregieren betroffene Devices; der affectedAsset + traegt die aggregierte Betroffenheit als Anzahl (Affected Device + Count), der eigentliche Geraetebeleg steht als Tabelle im + Ergebnisfenster. */} + {item.sourceProfileId === 'defender-for-iot-vulnerabilities' + ?
  • Affected Devices: {item.affectedAsset ?? '–'}
  • + :
  • Affected Object: {item.affectedAsset ?? '–'}
  • } }
  • Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}
  • diff --git a/src/importers/defender-for-iot-source-extractor.ts b/src/importers/defender-for-iot-source-extractor.ts index 460f5e4..fa3c09a 100644 --- a/src/importers/defender-for-iot-source-extractor.ts +++ b/src/importers/defender-for-iot-source-extractor.ts @@ -2,8 +2,10 @@ import type { JsonValue } from '../domain/canonical-model'; import { buildDefenderVulnerabilityModel, defenderTableRole, + type DefenderCve, type DefenderDevice, type DefenderObservation, + type DefenderOccurrence, type DefenderSourceRow, type DefenderSourceTable } from '../domain/defender-for-iot'; @@ -48,32 +50,49 @@ function field(key: string, label: string, originalValue: JsonValue, decodedValu return { key, label, originalValue, decodedValue, source }; } -function occurrenceCandidate( +function deviceLabel(device: DefenderDevice | undefined, fallback: string): string { + return device?.displayName || device?.ip || fallback; +} + +function cell(value: string): string { + // Zellen einer Markdown-Tabelle: Pipe und Zeilenumbruch wuerden die + // Tabellenstruktur brechen, deshalb neutralisiert. + return value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ').trim(); +} + +// Die betroffenen Devices je CVE als Markdown-Tabelle. Sie wird im +// Ergebnisfenster als Tabelle ausgewertet (siehe RichTextValue) und ersetzt so +// die frueher als Rohtext gezeigten Source Observations. Eine Zeile je Device +// (Occurrence) mit den meldenden Sensoren und dem Standort. +function affectedDevicesTable(occurrences: DefenderOccurrence[], deviceById: Map): string { + const header = '| Affected Device | Scanner | Site |\n| --- | --- | --- |'; + const rows = occurrences.map(occurrence => `| ${cell(deviceLabel(deviceById.get(occurrence.deviceId), occurrence.deviceId))} | ${cell(occurrence.sensors.join(', '))} | ${cell(occurrence.sites.join(', '))} |`); + return [header, ...rows].join('\n'); +} + +function cveCandidate( document: DecodedSourceDocument, - occurrence: ReturnType['occurrences'][number], - device: DefenderDevice + cve: DefenderCve, + occurrences: DefenderOccurrence[], + deviceById: Map ): FindingCandidate { - const first = occurrence.observations[0]; - const severityObservation = occurrence.observations.find(observation => observation.severity === occurrence.severity) ?? first; + const allObservations = occurrences.flatMap(occurrence => occurrence.observations); + const first = allObservations[0]; + const severityObservation = allObservations.find(observation => observation.severity === cve.severity) ?? first; const source = { fileName: document.file.name, tableIndex: first.tableIndex, rowIndex: first.rowIndex }; - const observations = occurrence.observations.map(observationValue); + const observations = allObservations.map(observationValue); + const devicesTable = affectedDevicesTable(occurrences, deviceById); return { version: 1, - id: `defender-occurrence-${first.tableIndex}-${first.rowIndex}`, + id: `defender-cve-${cve.id}`, source, fields: [ - field('cve', 'CVE', occurrence.cve, occurrence.cve, source), - field('device-identity', 'Device Identity', occurrence.deviceId, occurrence.deviceId, source), - field('identity-method', 'Identity Method', device.identityMethod, device.identityMethod, source), - field('identity-confidence', 'Identity Confidence', device.identityConfidence, device.identityConfidence, source), - field('identity-provenance', 'Identity Provenance', device.identityProvenance, device.identityProvenance, source), - field('name', 'Name', first.name, first.name, source), - field('ip', 'IP', first.ip, first.ip, source), - field('site', 'Site', first.site, first.site, source), - field('reporting-sensors', 'Reporting Sensors', occurrence.sensors, occurrence.sensors.join(', '), source), + field('cve', 'CVE', cve.id, cve.id, source), + field('affected-device-count', 'Affected Device Count', cve.deviceIds.length, String(cve.deviceIds.length), source), field('severity', 'Severity', severityObservation.severitySource, severityObservation.severitySource, source), - field('description', 'Beschreibung', occurrence.description, occurrence.description, source), + field('description', 'Description', cve.description, cve.description, source), field('observation-count', 'Observation Count', observations.length, String(observations.length), source), + field('affected-devices', 'Affected Devices', devicesTable, devicesTable, source), field('source-observations', 'Source Observations', observations, JSON.stringify(observations), source) ] }; @@ -95,13 +114,14 @@ export const defenderForIotSourceExtractor: SourceExtractor = { }, extract(document) { const model = buildDefenderVulnerabilityModel(sourceTables(document)); + const deviceById = new Map(model.devices.map(device => [device.id, device])); + const occurrencesByCve = new Map(); + for (const occurrence of model.occurrences) { + occurrencesByCve.set(occurrence.cve, [...(occurrencesByCve.get(occurrence.cve) ?? []), occurrence]); + } return { version: 1, - candidates: model.occurrences.map(occurrence => occurrenceCandidate( - document, - occurrence, - model.devices.find(device => device.id === occurrence.deviceId)! - )), + candidates: model.cves.map(cve => cveCandidate(document, cve, occurrencesByCve.get(cve.id) ?? [], deviceById)), diagnostics: model.diagnostics.map(diagnostic => ({ severity: diagnostic.severity, code: diagnostic.code, 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 084b7cc..c2ed3f6 100644 --- a/src/profiles/defender-for-iot/defender-for-iot-definition.ts +++ b/src/profiles/defender-for-iot/defender-for-iot-definition.ts @@ -29,12 +29,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = { { role: 'rule-id', headers: [{ pattern: /^CVE$/i, contribution: 1, message: 'CVE is the source-native finding identifier.' }] }, { role: 'description', headers: [{ pattern: /^(?:Beschreibung|Description)$/i, contribution: 1, message: 'The field contains the CVE description.' }] }, { role: 'severity', headers: [{ pattern: /^Severity$/i, contribution: 1, message: 'The field contains the vulnerability severity.' }] }, - { role: 'asset', headers: [ - { pattern: /^Device Identity$/i, contribution: 1, message: 'The extractor supplied the normalized device identity.' }, - { pattern: /^Name$/i, contribution: 0.9, message: 'Name identifies the source asset.' } - ] }, - { role: 'category', headers: [{ pattern: /^Site$/i, contribution: 0.92, message: 'Site groups affected assets.' }] }, - { role: 'score', headers: [{ pattern: /^TotalCVEs$/i, contribution: 0.92, message: 'TotalCVEs is the source asset total.' }] }, + { role: 'asset', headers: [{ pattern: /^Affected Device Count$/i, contribution: 0.92, message: 'The aggregated affected-device count stands in for the affected object of this CVE.' }] }, { role: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] } ], normalization: { severity: commonSeverityMappings, status: commonStatusMappings }, @@ -48,7 +43,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = { { value: 'all', label: 'All Vulnerabilities', patterns: [] }, { value: 'deviation', label: 'Vulnerabilities', patterns: [/^critical$/i, /^high$/i, /^medium$/i, /^low$/i] } ], - resultSectionHeaders: [/^Source Observations$/i] + resultSectionHeaders: [/^Affected Devices$/i] }, - comparisonKeys: [['rule-id', 'asset']] + comparisonKeys: [['rule-id']] }; diff --git a/tests/importers/defender-for-iot.test.ts b/tests/importers/defender-for-iot.test.ts index 313e9ed..5a87d20 100644 --- a/tests/importers/defender-for-iot.test.ts +++ b/tests/importers/defender-for-iot.test.ts @@ -114,14 +114,16 @@ describe('Microsoft Defender for IoT vulnerability model', () => { expect(detailOnly.devices[0].severity.low.state).toBe('unavailable'); }); - it('selects the versioned profile and emits one candidate per device and CVE', async () => { + 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('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('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 }); @@ -129,10 +131,17 @@ describe('Microsoft Defender for IoT vulnerability model', () => { 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(2); + 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([1, 2]); + 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 @@ -149,11 +158,14 @@ describe('Microsoft Defender for IoT vulnerability model', () => { expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] }); expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingOccurrences).toHaveLength(1); - expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toContain('mac:001122334455'); + // 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(2); - expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(1); + 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 () => {