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 7d3ef5f5ae
commit 5fbbda22b3
6 changed files with 86 additions and 40 deletions

View File

@ -1025,7 +1025,7 @@ Status: abgeschlossen
Verifizierte Erweiterung vom 2026-09-10:
- Der Defender-for-IoT-Extractor 1.0.0 gruppiert Detailzeilen deterministisch zu Device-mal-CVE-Vorkommen, bewahrt jede Sensorbeobachtung mit Tabellen- und Zeilenlocator als strukturierte Evidenz und laesst Summary-Zeilen ohne CVE als nicht-findingbezogene Quelltabelle bestehen.
- Der Defender-for-IoT-Extractor 1.0.0 gruppiert Detailzeilen deterministisch zu Device-mal-CVE-Vorkommen und projiziert daraus fuer die generische Pipeline genau ein aggregiertes Finding je CVE, sodass Work eine CVE nicht mehr je Device dupliziert. Die betroffenen Devices erscheinen nicht mehr als eigene Findingfelder, sondern als `Affected Devices`-Tabelle (Device, Scanner, Site, eine Zeile je Device) im Ergebnisfenster; das Feld traegt eine Markdown-Tabelle, die dort ausgewertet wird. Die maschinenlesbaren `Source Observations` bleiben mit Tabellen- und Zeilenlocator als strukturierte Evidenz erhalten (die Defender-Sonderansicht rekonstruiert daraus ihr Modell) und werden in der Lesespalte ausgeblendet. Summary-Zeilen ohne CVE bestehen weiter als nicht-findingbezogene Quelltabelle.
- Identitaet folgt MAC, danach gueltiger IP-Adresse, danach normalisiertem Namen im Site-Scope. Wiederverwendete Adressen, unvollstaendige Identitaeten, widerspruechliche CVE-Metadaten, befuellte `NULL`-Spalten, Dubletten und Summary-/Detailabweichungen erzeugen reviewbare Diagnosen.
- 33 fokussierte synthetische Import-, Profil-, Persistenzprojektions-, Datahub-Readiness- und Dashboardtests sowie der Gesamtlauf mit 764 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitspruefung, Designpruefung und vollstaendiger Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzmigration; die Phase bleibt abgeschlossen.
@ -1826,6 +1826,10 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 2026-09-10 | Defender-Betroffenheitszahl von der Rolle `score` auf `asset` umgestellt: In ASSESSOR ist `score` die Risikomagnitude (PingCastle bildet darauf seine Points ab und leitet daraus sogar die Severity ab), 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`). Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. |
| 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl (aus dem Score Affected Device Count) statt eines leeren `Affected Object`, da die CVE keinen einzelnen Asset mehr traegt. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. |
| 2026-09-10 | Defender-Ergebnisfenster auf eine Device-Tabelle umgestellt: Statt der rohen `Source Observations`-JSON zeigt der Result-Button einer CVE nun eine `Affected Devices`-Tabelle (Device, Scanner, Site, eine Zeile je Device), die der Extractor als Markdown-Tabelle liefert und das Ergebnisfenster ueber `RichTextValue` auswertet. Die frueheren Einzelfelder Affected Devices, Reporting Sensors und Site entfallen im Finding (kein Asset-/Kategorie-Mapping mehr; Vergleich weiter ueber `rule-id`), sodass sich Popup und Lesespalte nicht mehr doppeln. Die `Source Observations` bleiben als maschinenlesbare Evidenz erhalten und werden in der Lesespalte ausgeblendet; die Defender-Sonderansicht rekonstruiert ihr Modell unveraendert daraus. Fokussierte Defender-Import-, Dashboard- und Titeltests sowie der Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine SQL- oder Persistenzmigration; Profilversion 1.0.0 bleibt vor Erstfreigabe unveraendert; Phase 13A bleibt abgeschlossen. |
| 2026-09-10 | Phase-13A-Defender-Projektion auf CVE-Aggregation umgestellt: Der Source Extractor 1.0.0 emittiert nun genau ein Finding je CVE statt je Device-mal-CVE-Vorkommen, sodass Work eine CVE nicht mehr je betroffenem Device dupliziert. Die Devices werden im neuen Feld `Affected Devices` samt Anzahl zusammengefasst, jede Sensorbeobachtung aller Devices bleibt als strukturierte Evidenz erhalten, das Profil mappt `Affected Devices` als Asset und `Affected Device Count` als Score, und der Vergleichsschluessel ist auf `rule-id` (CVE) reduziert. Die Defender-Sonderansicht rekonstruiert das vollstaendige Device-mal-CVE-Modell weiterhin aus den erhaltenen Beobachtungen. Fokussierte Defender-Import-, Dashboard- und Titeltests sowie der Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine SQL- oder Persistenzmigration; Profilversion 1.0.0 bleibt vor Erstfreigabe unveraendert; 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

@ -181,6 +181,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

@ -156,7 +156,13 @@ function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultProjection
<li>Source locator: {displayEnvironmentValue(item.sourceLocator)}</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 () => {