feat(defender-for-iot): aggregate affected devices in findings and update result display

This commit is contained in:
Binh 2026-09-10 12:22:41 +02:00
parent 0cf3a632cc
commit 57e57d1abb
6 changed files with 85 additions and 39 deletions

View File

@ -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. |

View File

@ -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))

View File

@ -224,7 +224,13 @@ export function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultPro
<li>Diagnostics: {item.policyDiagnosticCodes?.join(', ') || 'none'}</li>
</> : <>
<li>Rule ID: {item.sourceRuleId ?? '–'}</li>
<li>Affected Object: {item.affectedAsset ?? '–'}</li>
{/* 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'
? <li>Affected Devices: {item.affectedAsset ?? '–'}</li>
: <li>Affected Object: {item.affectedAsset ?? '–'}</li>}
</>}
<li>Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}</li>
</ul>

View File

@ -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, DefenderDevice>): 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<typeof buildDefenderVulnerabilityModel>['occurrences'][number],
device: DefenderDevice
cve: DefenderCve,
occurrences: DefenderOccurrence[],
deviceById: Map<string, DefenderDevice>
): 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<string, DefenderOccurrence[]>();
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,

View File

@ -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']]
};

View File

@ -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 () => {