feat(defender-for-iot): update to version 1.2.0 with new features and improvements

This commit is contained in:
Binh 2026-09-14 10:51:33 +02:00
parent a181d264bf
commit e0067311f3
10 changed files with 134 additions and 25 deletions

View File

@ -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: 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-02 | Phasen 6/13A: `c7c716c` vollstaendig uebernommen. Das Defender-Profil 1.2.0 mit Extractor 1.1.0 erkennt zusaetzlich das synthetische `Mock-Defender`-CSV-Paar aus CVE-Export und Device-Inventar ueber Header-Aliase, verwendet eine quellnative `Device ID` als erste Identitaetsmethode vor MAC, IP und Name, legt Inventar-Devices auch ohne CVE an und bewahrt jede nicht kanonische Spalte als zusaetzliches Quellfeld. Neue Manifestzeile 1.2.0, 1.1.0 ist nicht mehr aktuell; der Kompatibilitaetsschluessel bleibt wie im Original `-v2` bei Reimport-Pflicht, die lokale Datenbank enthaelt keine Defender-Importe. Die auf dieser Basis verpflichtenden Scoring-, Prioritaets- und Timeslot-Deklarationen des Profils bleiben unveraendert. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.895 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 13A: Aus `33d2019` nur die Dokumentation uebernommen. `docs/CANONICAL_MODEL.md` beschreibt `vulnerability_finding` als additive ASSESSOR-Erweiterung des externen `result_type`-Katalogs; der Kommentar an `CanonicalResultType` verweist darauf. Nicht uebernommen: die Backfill-Migration (frueher 023) samt Katalogeintrag und Schema-Assertions, weil die neu angelegte lokale Datenbank keine Defender-Occurrences enthaelt, sowie auf Nutzerentscheidung der lesende Outcome-Fallback in `unified-result.ts` mit seinem Test; neue Importe erhalten `warning` bereits ueber `missingStatusOutcome`. Keine Verhaltens-, API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/13A: `fe71246` uebernommen; `e048295` (NVD-CVE-Anreicherung) auf Nutzerentscheidung vorerst ausgelassen. Der Result-Contract kennt den Typ `vulnerability_finding`; Defender-Findings erhalten ihn. Ein Profil kann `missingStatusOutcome` deklarieren, Defender setzt `warning`, weil jede CVE-Zeile selbst der Befund ist. Das Defender-Profil steigt auf 1.1.0 mit Kompatibilitaetsschluessel `-v2` (semantic-breaking, Reimport erforderlich; die lokale Datenbank enthaelt keine Defender-Importe), 1.0.0 ist nicht mehr aktuell. Der Work-Filter `Deviation` aller Scanner nutzt statt der Regex-Liste `UNIVERSAL_DEVIATION_PATTERNS` nun `isDeviationRecord` und damit das kanonische Result-DB-Praedikat; die GPO-Statuslogik, der Statuscache und die uebrigen Statusfilter der Basis bleiben unveraendert davor. Alle bestehenden Work-, Filter- und Scannertests bestehen unveraendert; eine vollstaendige Gleichwertigkeit fuer jeden realen Statuswert ist damit nicht bewiesen. Migration `027_vulnerability_finding_result_type.sql` (frueher 022) erweitert nur den Check-Constraint und schreibt keine Occurrences um; ohne die ausgelassene CVE-Tabelle. Vor der Anwendung wurde das verifizierte Backup `backups/assessor-pre-027-20261002-123608.dump` erstellt. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |

View File

@ -32,7 +32,7 @@ Das Schema steht in `src/domain/tool-profile-definition.ts`. Die gemeinsame Lauf
| PingCastle | 1.0.0 | Healthcheck, Risk Scores und Regelkennungen |
| CIS-CAT Pro | 1.0.0 | Assessment Results, Recommendation und Pass/Fail |
| Maester | 1.0.0 | flache TestResults-Exporte, MT-IDs und ResultDetail |
| Microsoft Defender for IoT | 1.0.0 | Asset-Summary, CVE-Details, Device-Identitaet und Sensorprovenienz |
| Microsoft Defender for IoT | 1.2.0 | Asset-Summary, Mock-CVE-CSV, Device-Inventar, Device-ID-Verknuepfung und Sensorprovenienz |
Die Definitionen liegen jeweils im Verzeichnis des Werkzeugs. `profile-definition-catalog.ts` stellt sie der Erkennungsengine bereit; `profile-catalog.ts` stellt die daraus erzeugten Laufzeitprofile für den Import und die Oberfläche bereit.
@ -65,7 +65,7 @@ Ein neues Werkzeug benötigt:
Formatparser, Erkennungsalgorithmus und React-Komponenten müssen dafür nicht geändert werden.
Microsoft Defender for IoT verwendet weiterhin den generischen Excel-/CSV-Formatadapter. Ein kleiner Source Extractor verbindet Summary- und Detailtabellen, erzeugt genau ein Finding-Vorkommen je normalisiertem Device und CVE und bewahrt wiederholte Sensorzeilen als Evidenz. Summary-Werte ohne Detail-CVE bleiben separate Aggregate und werden nicht in Findings umgewandelt. Die fachlichen CVE-, Device- und Site-Projektionen liegen ausserhalb der React-Komponente in einem deterministischen Domain-Modell.
Microsoft Defender for IoT verwendet weiterhin den generischen Excel-/CSV-Formatadapter. Ein kleiner Source Extractor verarbeitet sowohl das bisherige Summary-/Detailtabellenpaar als auch die synthetischen `Mock-Defender`-CSV-Dateien `cves.csv` und `device_inventory.csv`. Die Mock-CVE-Datei wiederholt Site und Sensor als ausdruecklich simulierte Fixture-Felder, damit ein Einzelimport die CVE-Ansicht vollstaendig speist; bei einem Mehrdateiimport verbindet das Domain-Modell CVE- und Inventarzeilen weiterhin ausschliesslich ueber die explizite `Device ID`. Fehlende Kontext- oder Beschreibungswerte werden nicht erfunden. Der Extractor erzeugt genau ein Finding-Vorkommen je Device und CVE, aggregiert diese je CVE und bewahrt CVSS Score, Component, Status sowie wiederholte Sensorzeilen als Evidenz. Summary-Werte ohne Detail-CVE bleiben separate Aggregate und werden nicht in Findings umgewandelt. Die fachlichen CVE-, Device- und Site-Projektionen liegen ausserhalb der React-Komponente in einem deterministischen Domain-Modell.
## Darstellung von Textfeldern

View File

@ -4,7 +4,8 @@ export const DEFENDER_FOR_IOT_PROFILE_ID = 'defender-for-iot-vulnerabilities';
export type DefenderSeverity = 'critical' | 'high' | 'medium' | 'low';
export type DefenderDetailState = 'complete' | 'aggregate-only' | 'detail-only' | 'mismatch' | 'zero' | 'unavailable';
export type DefenderIdentityMethod = 'mac' | 'ip' | 'name' | 'unresolved';
export type DefenderIdentityMethod = 'device-id' | 'mac' | 'ip' | 'name' | 'unresolved';
export type DefenderTableRole = 'summary' | 'detail' | 'inventory';
export interface DefenderSourceRow {
tableIndex: number;
@ -108,7 +109,9 @@ export interface DefenderVulnerabilityModel {
available: boolean;
summaryTableCount: number;
detailTableCount: number;
inventoryTableCount: number;
summaryRowCount: number;
inventoryRowCount: number;
devices: DefenderDevice[];
cves: DefenderCve[];
occurrences: DefenderOccurrence[];
@ -121,6 +124,19 @@ export interface DefenderVulnerabilityModel {
const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low'];
const summaryHeaders = ['name', 'ip', 'site', 'sensor', 'totalcves', 'critical', 'high', 'medium', 'low'];
const detailHeaders = ['name', 'ip', 'site', 'sensor', 'cve', 'severity', 'beschreibung'];
const mockDetailHeaders = ['deviceid', 'devicename', 'ipaddress', 'cve', 'cvssscore', 'severity', 'component', 'status'];
const inventoryHeaders = ['deviceid', 'name', 'ipv4address', 'macaddress'];
const canonicalObservationHeaders = new Set(['name', 'devicename', 'ip', 'ipaddress', 'ipv4address', 'site', 'sensor', 'cve', 'severity', 'beschreibung', 'description']);
const headerAliases: Record<string, string[]> = {
name: ['name', 'devicename'],
ip: ['ip', 'ipaddress', 'ipv4address'],
deviceid: ['deviceid'],
site: ['site'],
sensor: ['sensor'],
cve: ['cve'],
severity: ['severity'],
beschreibung: ['beschreibung', 'description']
};
function text(value: JsonValue | undefined): string {
if (value === null || value === undefined) return '';
@ -139,7 +155,8 @@ function normalizedText(value: string): string {
function value(row: DefenderSourceRow, header: string): JsonValue | undefined {
const target = normalizedHeader(header);
const match = Object.keys(row.fields).find(key => normalizedHeader(key) === target);
const aliases = headerAliases[target] ?? [target];
const match = Object.keys(row.fields).find(key => aliases.includes(normalizedHeader(key)));
return match === undefined ? undefined : row.fields[match];
}
@ -169,10 +186,12 @@ function severity(value: string): DefenderSeverity | 'unknown' {
return severities.find(item => item === normalized) ?? 'unknown';
}
export function defenderTableRole(headersInput: string[]): 'summary' | 'detail' | undefined {
export function defenderTableRole(headersInput: string[]): DefenderTableRole | undefined {
const headers = new Set(headersInput.map(normalizedHeader));
if (detailHeaders.every(header => headers.has(header))) return 'detail';
if (mockDetailHeaders.every(header => headers.has(header))) return 'detail';
if (summaryHeaders.every(header => headers.has(header))) return 'summary';
if (inventoryHeaders.every(header => headers.has(header)) && !headers.has('cve')) return 'inventory';
return undefined;
}
@ -190,6 +209,8 @@ function identitySeed(row: DefenderSourceRow): IdentitySeed {
const displayName = text(value(row, 'Name'));
const ip = text(value(row, 'IP'));
const site = text(value(row, 'Site'));
const deviceId = text(value(row, 'Device ID'));
if (deviceId) return { method: 'device-id', base: normalizedText(deviceId), site, displayName, ip, provenance: 'source-native Device ID', confidence: 'high' };
const macValue = [displayName, ip].find(isMac);
if (macValue) return { method: 'mac', base: normalizedText(macValue).replace(/[:-]/g, ''), site, displayName, ip, provenance: macValue === displayName ? 'valid MAC from Name' : 'valid MAC from IP', confidence: 'high' };
const ipValue = [ip, displayName].find(item => isIpv4(item) || isIpv6(item));
@ -247,9 +268,11 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
const diagnostics: DefenderDiagnostic[] = [];
const summaryTables = tables.filter(table => defenderTableRole(table.headers) === 'summary');
const detailTables = tables.filter(table => defenderTableRole(table.headers) === 'detail');
const inventoryTables = tables.filter(table => defenderTableRole(table.headers) === 'inventory');
const summaryRows = summaryTables.flatMap(table => table.rows);
const detailRows = detailTables.flatMap(table => table.rows);
const allRows = [...summaryRows, ...detailRows];
const inventoryRows = inventoryTables.flatMap(table => table.rows);
const allRows = [...summaryRows, ...detailRows, ...inventoryRows];
const identities = identityKeys(allRows, diagnostics);
for (const row of summaryRows) {
@ -302,7 +325,9 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
const severitySource = text(value(row, 'Severity'));
const additionalSourceFields: Record<string, JsonValue> = {};
for (const [label, fieldValue] of Object.entries(row.fields)) {
if (normalizedHeader(label) === 'null' && text(fieldValue)) additionalSourceFields[label] = fieldValue;
if (!canonicalObservationHeaders.has(normalizedHeader(label)) && (normalizedHeader(label) !== 'null' || text(fieldValue))) {
additionalSourceFields[label] = fieldValue;
}
}
return [{
id: `observation-${row.tableIndex}-${row.rowIndex}`,
@ -386,27 +411,35 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
const occurrenceByDevice = new Map<string, DefenderOccurrence[]>();
for (const occurrence of occurrences) occurrenceByDevice.set(occurrence.deviceId, [...(occurrenceByDevice.get(occurrence.deviceId) ?? []), occurrence]);
const inventoryByDevice = new Map<string, DefenderSourceRow[]>();
for (const row of inventoryRows) {
const id = identities.get(row)!.id;
inventoryByDevice.set(id, [...(inventoryByDevice.get(id) ?? []), row]);
}
const severitiesWithDetail = new Set(occurrences.map(occurrence => occurrence.severity).filter((item): item is DefenderSeverity => item !== 'unknown'));
const deviceIds = new Set([...summaryByDevice.keys(), ...occurrenceByDevice.keys()]);
const deviceIds = new Set([...summaryByDevice.keys(), ...occurrenceByDevice.keys(), ...inventoryByDevice.keys()]);
const devices: DefenderDevice[] = [...deviceIds].map(id => {
const summaryGroup = summaryByDevice.get(id) ?? [];
const detailGroup = occurrenceByDevice.get(id) ?? [];
const representativeRow = summaryGroup[0] ?? detailRows.find(row => identities.get(row)?.id === id)!;
const inventoryGroup = inventoryByDevice.get(id) ?? [];
const deviceDetailRows = detailRows.filter(row => identities.get(row)?.id === id);
const representativeRow = summaryGroup[0] ?? inventoryGroup[0] ?? deviceDetailRows[0];
const identity = identities.get(representativeRow)!;
const summaryRow = summaryGroup[0];
const detailCounts = emptyCounts();
for (const occurrence of detailGroup) if (occurrence.severity !== 'unknown') detailCounts[occurrence.severity] += 1;
const summaryCounts = Object.fromEntries(severities.map(item => [item, summaryRow ? numeric(value(summaryRow, item)) : undefined])) as Record<DefenderSeverity, number | undefined>;
const measures = Object.fromEntries(severities.map(item => [item, detailState(summaryCounts[item], detailCounts[item], severitiesWithDetail.has(item))])) as Record<DefenderSeverity, DefenderSeverityMeasure>;
const sourceRows = [...summaryGroup, ...detailRows.filter(row => identities.get(row)?.id === id)];
const sourceRows = [...summaryGroup, ...inventoryGroup, ...deviceDetailRows];
const firstValue = (header: string) => sourceRows.map(row => text(value(row, header))).find(Boolean) ?? '';
return {
id,
identityMethod: identity.method,
identityConfidence: identity.confidence,
identityProvenance: identity.provenance,
displayName: text(value(representativeRow, 'Name')),
ip: text(value(representativeRow, 'IP')),
site: text(value(representativeRow, 'Site')),
displayName: firstValue('Name'),
ip: firstValue('IP'),
site: firstValue('Site'),
sensors: [...new Set(sourceRows.map(row => text(value(row, 'Sensor'))).filter(Boolean))].sort(),
totalCves: summaryRow ? numeric(value(summaryRow, 'TotalCVEs')) : undefined,
severity: measures,
@ -454,10 +487,12 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
}).sort((left, right) => left.name.localeCompare(right.name));
return {
available: summaryTables.length > 0 || detailTables.length > 0,
available: summaryTables.length > 0 || detailTables.length > 0 || inventoryTables.length > 0,
summaryTableCount: summaryTables.length,
detailTableCount: detailTables.length,
inventoryTableCount: inventoryTables.length,
summaryRowCount: summaryRows.length,
inventoryRowCount: inventoryRows.length,
devices,
cves,
occurrences,

View File

@ -25,7 +25,8 @@ export interface ProcessingVersionManifest extends ToolProcessingContract {
export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] = [
{ profileId: 'generic', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'generic-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true },
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: false },
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'], current: true },
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'], current: false },
{ profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.2.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['recognition', 'extraction', 'finding-identity', 'evidence'], current: true },
{ profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'purple-knight-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'purple-knight', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'purple-knight', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false },

View File

@ -69,7 +69,7 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article>
<article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
</section>
<p className="defender-context"><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span></p>
<p className="defender-context"><span>{(model.summaryRowCount + model.inventoryRowCount).toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span></p>
<nav className="defender-tabs" aria-label="Defender vulnerability views">
{([['cves', 'CVEs'], ['devices', 'Devices'], ['sites', 'Sites']] as const).map(([id, title]) => <button type="button" key={id} aria-pressed={view === id} onClick={() => setView(id)}>{title}</button>)}
</nav>

View File

@ -100,13 +100,14 @@ function cveCandidate(
export const defenderForIotSourceExtractor: SourceExtractor = {
id: 'defender-for-iot-vulnerability-source-extractor',
version: '1.0.0',
version: '1.1.0',
detect(document) {
const roles = document.tables.map(table => defenderTableRole(headerFor(table))).filter(Boolean);
if (!roles.length) return { score: 0, evidence: [] };
const both = roles.includes('summary') && roles.includes('detail');
return {
score: both ? 1 : 0.92,
toolId: 'defender-for-iot-vulnerabilities',
evidence: [both
? 'A Defender for IoT asset summary and detailed vulnerability table were found.'
: `A Defender for IoT ${roles[0]} table was found.`]

View File

@ -20,18 +20,21 @@ export const defenderForIotDefinition: ToolProfileDefinition = {
reason: 'The Defender for IoT vulnerability export is a point-in-time CVE inventory without a stable scan scope or observation time for timeslot comparison.'
},
name: 'Microsoft Defender for IoT',
version: '1.1.0',
version: '1.2.0',
processing: {
mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking',
reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation']
reimportRequired: true, affectedDimensions: ['recognition', 'extraction', 'finding-identity', 'evidence']
},
sourceSignals: [
{ scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' },
{ scope: 'header', pattern: /^CVE$/i, contribution: 0.38, message: 'The detail table exposes CVE identifiers.' },
{ scope: 'header', pattern: /^Sensor$/i, contribution: 0.14, message: 'The source records a reporting sensor.' },
{ scope: 'header', pattern: /^Beschreibung$/i, contribution: 0.18, message: 'The detail table exposes the Defender CVE description field.' },
{ scope: 'header', pattern: /^Critical$/i, contribution: 0.1, message: 'The asset summary exposes Critical counts.' }
{ scope: 'header', pattern: /^Critical$/i, contribution: 0.1, message: 'The asset summary exposes Critical counts.' },
{ scope: 'header', pattern: /^Device ID$/i, contribution: 0.24, message: 'The source exposes the Defender device identifier.' },
{ scope: 'header', pattern: /^CVSS Score$/i, contribution: 0.2, message: 'The CVE export exposes a CVSS score.' },
{ scope: 'header', pattern: /^Component$/i, contribution: 0.1, message: 'The CVE export identifies the affected component.' }
],
tableRules: {
preferredNames: [

View File

@ -43,12 +43,14 @@ describe('processing compatibility contracts', () => {
it('uses the recorded profile when a caller supplies a different detected tool id', () => {
const legacy = file('defender-old', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0');
const current = file('defender-current', 'defender-for-iot-vulnerabilities', '1.1.0', '1.0.0');
const previous = file('defender-previous', 'defender-for-iot-vulnerabilities', '1.1.0', '1.0.0');
const current = file('defender-current', 'defender-for-iot-vulnerabilities', '1.2.0', '1.0.0');
current.processedWith.normalizationVersion = '1.1.0';
current.processedWith.presentationVersion = '1.0.0';
current.processedWith.compatibilityKey = 'defender-for-iot-vulnerabilities-v2';
expect(processingReadiness('microsoft-defender-for-iot', legacy.processedWith)).toBe('incompatible');
expect(processingReadiness('microsoft-defender-for-iot', previous.processedWith)).toBe('reimport-recommended');
expect(processingReadiness('microsoft-defender-for-iot', current.processedWith)).toBe('current');
expect(resolvedCompatibilityKey('microsoft-defender-for-iot', current.processedWith))
.toBe('defender-for-iot-vulnerabilities-v2');

View File

@ -125,7 +125,7 @@ describe('Datahub client processing readiness', () => {
it('assesses the stored profile contract independently from the detected tool identity', () => {
const defender = withDetectedTool(
file('run-defender', 'defender-for-iot-vulnerabilities', '1.1.0', 'defender-for-iot-vulnerabilities-v2'),
file('run-defender', 'defender-for-iot-vulnerabilities', '1.2.0', 'defender-for-iot-vulnerabilities-v2'),
'microsoft-defender-for-iot'
);
defender.processedWith.mappingVersion = '1.0.0';

View File

@ -8,6 +8,7 @@ import { createCanonicalImportBatch } from '../../src/importers/canonical-import
import { importProfiles } from '../../src/profiles/profile-catalog';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
import { defenderModelForDataset } from '../../src/features/defender-for-iot/defender-dataset-model';
import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness';
import { createScannerUnifiedResult } from '../../src/domain/unified-result';
@ -115,6 +116,71 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
expect(detailOnly.devices[0].severity.low.state).toBe('unavailable');
});
it('joins the mock CVE and inventory schemas through the source-native Device ID', () => {
const model = buildDefenderVulnerabilityModel([
table(0, ['Device ID', 'Device Name', 'IP Address', 'CVE', 'CVSS Score', 'Severity', 'Component', 'Status'], [{
'Device ID': 'DEV-OT-001', 'Device Name': 'PLC-LINE1-01', 'IP Address': '192.0.2.21',
CVE: 'CVE-2099-1001', 'CVSS Score': 9.1, Severity: 'Critical', Component: 'Synthetic component', Status: 'Open'
}]),
table(1, ['Name', 'IPv4 Address', 'MAC Address', 'Site', 'Sensor', 'Device ID'], [{
Name: 'PLC-LINE1-01', 'IPv4 Address': '192.0.2.21', 'MAC Address': '02:00:00:00:00:21',
Site: 'Synthetic site', Sensor: 'SENS-01', 'Device ID': 'DEV-OT-001'
}])
]);
expect(model).toMatchObject({ available: true, detailTableCount: 1, inventoryTableCount: 1, inventoryRowCount: 1 });
expect(model.devices).toHaveLength(1);
expect(model.devices[0]).toMatchObject({
id: 'device-id:dev-ot-001', identityMethod: 'device-id', displayName: 'PLC-LINE1-01',
ip: '192.0.2.21', site: 'Synthetic site', sensors: ['SENS-01']
});
expect(model.cves[0]).toMatchObject({ id: 'CVE-2099-1001', deviceIds: ['device-id:dev-ot-001'] });
expect(model.observations[0].additionalSourceFields).toMatchObject({
'Device ID': 'DEV-OT-001', 'CVSS Score': 9.1, Component: 'Synthetic component', Status: 'Open'
});
});
it('imports the synthetic Mock-Defender CVE and inventory CSVs with the Defender profile', async () => {
const bytes = new TextEncoder().encode([
'Device ID,Device Name,IP Address,Site,Sensor,CVE,CVSS Score,Severity,Component,Status',
'DEV-OT-001,PLC-LINE1-01,192.0.2.21,Riverbend Plant,SENS-RB-01,CVE-2099-1001,9.1,Critical,Synthetic component,Open',
'DEV-OT-002,HMI-LINE1-01,192.0.2.31,Riverbend Plant,SENS-RB-01,CVE-2099-1002,7.5,High,Synthetic component,Open',
'DEV-OT-004,HISTORIAN-01,192.0.2.51,Riverbend Plant,SENS-RB-01,CVE-2099-1005,8.2,High,Synthetic component,Mitigation planned',
'DEV-OT-005,RTU-WATER-01,192.0.2.61,Riverbend Plant,SENS-RB-01,CVE-2099-1006,6.4,Medium,Synthetic component,Accepted risk',
'DEV-OT-008,FIREWALL-OT-01,192.0.2.1,Riverbend Plant,SENS-RB-01,CVE-2099-1007,7.8,High,Synthetic component,Patch scheduled'
].join('\n'));
const imported = await importFile(new File([bytes], 'cves.csv', { type: 'text/csv' }), { profiles: importProfiles });
const inventoryBytes = new TextEncoder().encode([
'Name,IPv4 Address,MAC Address,Site,Sensor,Device Id',
'PLC-LINE1-01,192.0.2.21,02:00:00:00:00:21,Riverbend Plant,SENS-RB-01,DEV-OT-001'
].join('\n'));
const inventory = await importFile(new File([inventoryBytes], 'device_inventory.csv', { type: 'text/csv' }), { profiles: importProfiles });
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
expect(imported.profileVersion).toBe('1.2.0');
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.1.0' });
expect(imported.extraction?.candidates).toHaveLength(5);
const observations = imported.extraction?.candidates[0].fields.find(field => field.label === 'Source Observations')?.originalValue;
expect(observations).toEqual(expect.arrayContaining([expect.objectContaining({
site: 'Riverbend Plant', sensor: 'SENS-RB-01',
additionalSourceFields: expect.objectContaining({ 'Device ID': expect.stringMatching(/^DEV-OT-/), 'CVSS Score': expect.any(String), Component: expect.any(String), Status: expect.any(String) })
})]));
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
sha256: 'f'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
}), importProfiles);
const canonical = createCanonicalImportBatch('defender-mock-cve-context', [entry], '2026-09-14T06:00:00.000Z').model;
const projected = canonicalModelToDataset(canonical);
expect(projected).not.toBeNull();
const model = defenderModelForDataset(projected!);
expect(model.cves).toHaveLength(5);
expect(model.cves.every(cve => cve.sites.join() === 'Riverbend Plant')).toBe(true);
expect(model.cves.every(cve => cve.sensors.join() === 'SENS-RB-01')).toBe(true);
expect(inventory.profileId).toBe('defender-for-iot-vulnerabilities');
expect(inventory.profileVersion).toBe('1.2.0');
expect(inventory.extraction?.candidates).toHaveLength(0);
expect(inventory.sheets[0].analysis.records).toHaveLength(1);
});
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([
@ -130,8 +196,8 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
const imported = await importFile(new File([bytes], 'defender-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles });
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
expect(imported.profileVersion).toBe('1.1.0');
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' });
expect(imported.profileVersion).toBe('1.2.0');
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.1.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(3);
@ -152,7 +218,7 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
detectedTool: { id: 'defender-for-iot-vulnerabilities' },
processedWith: {
profileId: 'defender-for-iot-vulnerabilities',
profileVersion: '1.1.0',
profileVersion: '1.2.0',
normalizationVersion: '1.1.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v2'
}