feat(defender-for-iot): aggregate affected devices in findings

Partial pick of 5fbbda2. One finding per CVE with an Affected Devices
table and device count; the Finding Reader hides the raw Source
Observations field.

Left out: the Affected Devices label in the Result DB row detail, since
the disclosure policy withholds affectedAsset for every row.
This commit is contained in:
Binh 2026-10-02 12:21:05 +02:00
parent f5a7828883
commit df894f762a
5 changed files with 75 additions and 38 deletions

View File

@ -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. 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 | | Datum | Änderung |
| 2026-10-02 | Phasen 6/7/13A: `5fbbda2` uebernommen. Der Defender-Extractor erzeugt nun ein Finding je CVE statt je Device-mal-CVE und liefert `Affected Device Count` sowie die Tabelle `Affected Devices` (Device, Scanner, Site); das Profil bildet die Anzahl auf die Asset-Rolle ab, nutzt `Affected Devices` als Ergebnisabschnitt und vergleicht nur noch ueber die CVE. Der Finding Reader blendet das Rohfeld `Source Observations` fuer Defender aus, es bleibt als Provenienz im Datensatz. Nicht uebernommen: die Umbenennung der Asset-Zeile im Result-DB-Zeilendetail, weil die Disclosure-Policy `affectedAsset` fuer jede Zeile zurueckhaelt und eine Device-Anzahl dort nicht erscheinen soll. Die Finding-Identitaet aendert sich wie im Original ohne Profilversionssprung; die neu angelegte lokale Datenbank enthaelt keine Defender-Importe, der Versionssprung folgt mit `c7c716c`. Keine API-, SQL- oder Persistenzmigration. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/7/13A: `7d3ef5f` (Microsoft Defender for IoT, Grundlage) auf Michaels Basis uebernommen. Profil `defender-for-iot-vulnerabilities` 1.0.0 mit Manifestzeile, Source Extractor, Domain-Modell, Client-Info-Panel sowie Titel je Datensatzprofil (`recordTitleFor`) in Work und Pathfinder. Angepasst: Das Profil deklariert die auf dieser Basis verpflichtenden `securityScoring` (kein nativer Gesamtscore, mit Begruendung), `priorityBasis` (native CVE-Severity) und `timeslotComparison` (nicht verfuegbar). Client Info fuehrt Defender als dritten Tab neben Overview und AI Overview; ein reiner Defender-Timeslot startet in der Defender-Sicht. `recordTitleFor` ersetzt nur den Titelkandidaten, die Maester-/CIS-Titelregeln bleiben. `canonicalModelsToDataset` laesst zusaetzlich Modelle mit befuellten Quelltabellen zu, das leere All-Records-Blatt bleibt bestehen. Die Kompatibilitaetspruefung bevorzugt den gespeicherten `processedWith.profileId`; der sichtbare Importvertrag steigt auf 1.4.0. Die kopierten `.client-info-tabs`-Regeln sind aus `ai-overview.css` entfernt und liegen nur noch in `defender-for-iot.css`. Nicht uebernommen: `.gitignore`-Eintrag, `--tile-inset`, der bereits vorhandene `fileProfile`-Vorrang und die alte Versionspruefung. Neues Profil, daher kein Reimport bestehender Importe; keine API-, SQL- oder Persistenzmigration. Die Produktversion wird am Ende der Defender-Serie einmal neu bewertet. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/7/13A: `7d3ef5f` (Microsoft Defender for IoT, Grundlage) auf Michaels Basis uebernommen. Profil `defender-for-iot-vulnerabilities` 1.0.0 mit Manifestzeile, Source Extractor, Domain-Modell, Client-Info-Panel sowie Titel je Datensatzprofil (`recordTitleFor`) in Work und Pathfinder. Angepasst: Das Profil deklariert die auf dieser Basis verpflichtenden `securityScoring` (kein nativer Gesamtscore, mit Begruendung), `priorityBasis` (native CVE-Severity) und `timeslotComparison` (nicht verfuegbar). Client Info fuehrt Defender als dritten Tab neben Overview und AI Overview; ein reiner Defender-Timeslot startet in der Defender-Sicht. `recordTitleFor` ersetzt nur den Titelkandidaten, die Maester-/CIS-Titelregeln bleiben. `canonicalModelsToDataset` laesst zusaetzlich Modelle mit befuellten Quelltabellen zu, das leere All-Records-Blatt bleibt bestehen. Die Kompatibilitaetspruefung bevorzugt den gespeicherten `processedWith.profileId`; der sichtbare Importvertrag steigt auf 1.4.0. Die kopierten `.client-info-tabs`-Regeln sind aus `ai-overview.css` entfernt und liegen nur noch in `defender-for-iot.css`. Nicht uebernommen: `.gitignore`-Eintrag, `--tile-inset`, der bereits vorhandene `fileProfile`-Vorrang und die alte Versionspruefung. Neues Profil, daher kein Reimport bestehender Importe; keine API-, SQL- oder Persistenzmigration. Die Produktversion wird am Ende der Defender-Serie einmal neu bewertet. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 7/13F: Aus `95ec322` die AI-Overview- und ISO-Mapping-Korrekturen uebernommen. Schlaegt eine Regeneration fehl oder laeuft sie noch, liefert der AI-Overview-Service zusaetzlich `previousSuccessfulRun`; das Panel zeigt die letzte gespeicherte Uebersicht mit der Fehlermeldung weiter, `Stale` gilt nur fuer einen erfolgreichen, veralteten Lauf. Ein gespeichertes ISO-Mapping bleibt lesbar, wenn Qdrant oder der Embeddingdienst nicht erreichbar ist; die Generierung bleibt dann deaktiviert. Synthetische Service- und Paneltests. Die Defender-Geraete-Risikoakzeptanz desselben Commits folgt mit den Defender-Commits. | | 2026-10-02 | Phase 7/13F: Aus `95ec322` die AI-Overview- und ISO-Mapping-Korrekturen uebernommen. Schlaegt eine Regeneration fehl oder laeuft sie noch, liefert der AI-Overview-Service zusaetzlich `previousSuccessfulRun`; das Panel zeigt die letzte gespeicherte Uebersicht mit der Fehlermeldung weiter, `Stale` gilt nur fuer einen erfolgreichen, veralteten Lauf. Ein gespeichertes ISO-Mapping bleibt lesbar, wenn Qdrant oder der Embeddingdienst nicht erreichbar ist; die Generierung bleibt dann deaktiviert. Synthetische Service- und Paneltests. Die Defender-Geraete-Risikoakzeptanz desselben Commits folgt mit den Defender-Commits. |
| 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. | | 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. |

View File

@ -209,6 +209,15 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
.filter(field => isPingCastleLinkedSectionField(field.label)) .filter(field => isPingCastleLinkedSectionField(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index)); .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) { if (hasStructuredResults) {
record.fields record.fields
.filter(field => isRedundantDetailField(field.label)) .filter(field => isRedundantDetailField(field.label))

View File

@ -2,8 +2,10 @@ import type { JsonValue } from '../domain/canonical-model';
import { import {
buildDefenderVulnerabilityModel, buildDefenderVulnerabilityModel,
defenderTableRole, defenderTableRole,
type DefenderCve,
type DefenderDevice, type DefenderDevice,
type DefenderObservation, type DefenderObservation,
type DefenderOccurrence,
type DefenderSourceRow, type DefenderSourceRow,
type DefenderSourceTable type DefenderSourceTable
} from '../domain/defender-for-iot'; } from '../domain/defender-for-iot';
@ -48,32 +50,49 @@ function field(key: string, label: string, originalValue: JsonValue, decodedValu
return { key, label, originalValue, decodedValue, source }; 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, document: DecodedSourceDocument,
occurrence: ReturnType<typeof buildDefenderVulnerabilityModel>['occurrences'][number], cve: DefenderCve,
device: DefenderDevice occurrences: DefenderOccurrence[],
deviceById: Map<string, DefenderDevice>
): FindingCandidate { ): FindingCandidate {
const first = occurrence.observations[0]; const allObservations = occurrences.flatMap(occurrence => occurrence.observations);
const severityObservation = occurrence.observations.find(observation => observation.severity === occurrence.severity) ?? first; 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 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 { return {
version: 1, version: 1,
id: `defender-occurrence-${first.tableIndex}-${first.rowIndex}`, id: `defender-cve-${cve.id}`,
source, source,
fields: [ fields: [
field('cve', 'CVE', occurrence.cve, occurrence.cve, source), field('cve', 'CVE', cve.id, cve.id, source),
field('device-identity', 'Device Identity', occurrence.deviceId, occurrence.deviceId, source), field('affected-device-count', 'Affected Device Count', cve.deviceIds.length, String(cve.deviceIds.length), 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('severity', 'Severity', severityObservation.severitySource, severityObservation.severitySource, 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('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) field('source-observations', 'Source Observations', observations, JSON.stringify(observations), source)
] ]
}; };
@ -95,13 +114,14 @@ export const defenderForIotSourceExtractor: SourceExtractor = {
}, },
extract(document) { extract(document) {
const model = buildDefenderVulnerabilityModel(sourceTables(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 { return {
version: 1, version: 1,
candidates: model.occurrences.map(occurrence => occurrenceCandidate( candidates: model.cves.map(cve => cveCandidate(document, cve, occurrencesByCve.get(cve.id) ?? [], deviceById)),
document,
occurrence,
model.devices.find(device => device.id === occurrence.deviceId)!
)),
diagnostics: model.diagnostics.map(diagnostic => ({ diagnostics: model.diagnostics.map(diagnostic => ({
severity: diagnostic.severity, severity: diagnostic.severity,
code: diagnostic.code, code: diagnostic.code,

View File

@ -44,12 +44,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = {
{ role: 'rule-id', headers: [{ pattern: /^CVE$/i, contribution: 1, message: 'CVE is the source-native finding identifier.' }] }, { 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: '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: 'severity', headers: [{ pattern: /^Severity$/i, contribution: 1, message: 'The field contains the vulnerability severity.' }] },
{ role: 'asset', headers: [ { 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.' }] },
{ 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: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] } { role: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] }
], ],
normalization: { severity: commonSeverityMappings, status: commonStatusMappings }, normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
@ -63,7 +58,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = {
{ value: 'all', label: 'All Vulnerabilities', patterns: [] }, { value: 'all', label: 'All Vulnerabilities', patterns: [] },
{ value: 'deviation', label: 'Vulnerabilities', patterns: [/^critical$/i, /^high$/i, /^medium$/i, /^low$/i] } { 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'); 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(); const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ 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'); ], { header: summaryHeaders }), 'Assets');
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ 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-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'); ], { header: detailHeaders }), 'Vulnerabilities');
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); 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 }); 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.profileId).toBe('defender-for-iot-vulnerabilities');
expect(imported.profileVersion).toBe('1.0.0'); expect(imported.profileVersion).toBe('1.0.0');
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '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).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.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, { const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified 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(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] });
expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingDefinitions).toHaveLength(1);
expect(canonical.findingOccurrences).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).toHaveLength(1);
expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) }); expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) });
expect(canonical.evidence[0].value).toHaveLength(2); expect(canonical.evidence[0].value).toHaveLength(3);
expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(1); // 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 () => { it('keeps a summary-only import viewable without creating finding definitions or occurrences', async () => {