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:
parent
6ccd6f009a
commit
2a80ca9867
|
|
@ -4231,6 +4231,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
|
||||||
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
||||||
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
||||||
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
||||||
|
| 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. |
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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']]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue