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 ae0e4b86dd
commit a4a166659d
10 changed files with 135 additions and 25 deletions

View File

@ -2,6 +2,8 @@
Status: lebendes Projektdokument Status: lebendes Projektdokument
Defender-Profil 1.2.0 2026-09-11 (Phase 6/13A): Das Profil verarbeitet die synthetischen `Mock-Defender`-Dateien `cves.csv` und `device_inventory.csv` ueber den generischen CSV-Adapter und verbindet sie ausschliesslich ueber `Device ID`. Site und Sensor stammen nur aus vorhandenen Feldern; CVSS, Component und Mock-Workflowstatus bleiben Evidenz und werden nicht als nativer Gesamtscore oder Pass/Fail interpretiert. Source Extractor 1.1.0 und Processing-Manifest 1.2.0 sind append-only registriert; Profil 1.1.0 bleibt lesekompatibel, wird aber als aktualisierbar markiert.
Defender-for-IoT-Drilldowns 2026-09-11 (Phase 7): CVE-, Device- und Site-Details erscheinen inline unter der ausgewaehlten Zeile mit stabilen, zugaenglichen Auf-/Zuklappzielen und gemeinsamer gelber Auswahlmarkierung. CVE-Evidenz zeigt betroffene Geraete flach; redundante Beschreibungs-, Observation- und Sensorangaben bleiben in den jeweils passenden Detailkontexten statt in der Uebersicht. Die Detailflaeche verwendet die gemeinsame Tile-Oberflaeche. Der NVD-Adapter uebernimmt `baseSeverity` aus den CVSS-Metriken. Reine Darstellungs-/Adapterkorrektur ohne Persistenz- oder Reimportaenderung. Defender-for-IoT-Drilldowns 2026-09-11 (Phase 7): CVE-, Device- und Site-Details erscheinen inline unter der ausgewaehlten Zeile mit stabilen, zugaenglichen Auf-/Zuklappzielen und gemeinsamer gelber Auswahlmarkierung. CVE-Evidenz zeigt betroffene Geraete flach; redundante Beschreibungs-, Observation- und Sensorangaben bleiben in den jeweils passenden Detailkontexten statt in der Uebersicht. Die Detailflaeche verwendet die gemeinsame Tile-Oberflaeche. Der NVD-Adapter uebernimmt `baseSeverity` aus den CVSS-Metriken. Reine Darstellungs-/Adapterkorrektur ohne Persistenz- oder Reimportaenderung.
Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit CVE-, Device- und Site-Ansichten. Sie verwendet denselben themenabhaengigen Client-Info-Canvas, dieselbe Kopfzeile, denselben Panel-Inset sowie die flache Kachel-, Flaechen-, Typografie-, Auswahl- und Responsive-Grammatik wie Overview. Scannerprojektion, Finding-Semantik, Scoreberechnung, Import- und Persistenzvertraege bleiben unveraendert; kein Reimport erforderlich. Defender-for-IoT-Client-Info-Stil 2026-09-11 (Phase 7): Microsoft Defender for IoT bleibt eine eigenstaendige Client-Info-Registerkarte mit CVE-, Device- und Site-Ansichten. Sie verwendet denselben themenabhaengigen Client-Info-Canvas, dieselbe Kopfzeile, denselben Panel-Inset sowie die flache Kachel-, Flaechen-, Typografie-, Auswahl- und Responsive-Grammatik wie Overview. Scannerprojektion, Finding-Semantik, Scoreberechnung, Import- und Persistenzvertraege bleiben unveraendert; kein Reimport erforderlich.

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 | | PingCastle | 1.0.0 | Healthcheck, Risk Scores und Regelkennungen |
| CIS-CAT Pro | 1.0.0 | Assessment Results, Recommendation und Pass/Fail | | CIS-CAT Pro | 1.0.0 | Assessment Results, Recommendation und Pass/Fail |
| Maester | 1.0.0 | flache TestResults-Exporte, MT-IDs und ResultDetail | | 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. 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. 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 ## 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 DefenderSeverity = 'critical' | 'high' | 'medium' | 'low';
export type DefenderDetailState = 'complete' | 'aggregate-only' | 'detail-only' | 'mismatch' | 'zero' | 'unavailable'; 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 { export interface DefenderSourceRow {
tableIndex: number; tableIndex: number;
@ -108,7 +109,9 @@ export interface DefenderVulnerabilityModel {
available: boolean; available: boolean;
summaryTableCount: number; summaryTableCount: number;
detailTableCount: number; detailTableCount: number;
inventoryTableCount: number;
summaryRowCount: number; summaryRowCount: number;
inventoryRowCount: number;
devices: DefenderDevice[]; devices: DefenderDevice[];
cves: DefenderCve[]; cves: DefenderCve[];
occurrences: DefenderOccurrence[]; occurrences: DefenderOccurrence[];
@ -121,6 +124,19 @@ export interface DefenderVulnerabilityModel {
const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low']; const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low'];
const summaryHeaders = ['name', 'ip', 'site', 'sensor', 'totalcves', '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 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 { function text(value: JsonValue | undefined): string {
if (value === null || value === undefined) return ''; if (value === null || value === undefined) return '';
@ -139,7 +155,8 @@ function normalizedText(value: string): string {
function value(row: DefenderSourceRow, header: string): JsonValue | undefined { function value(row: DefenderSourceRow, header: string): JsonValue | undefined {
const target = normalizedHeader(header); 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]; return match === undefined ? undefined : row.fields[match];
} }
@ -169,10 +186,12 @@ function severity(value: string): DefenderSeverity | 'unknown' {
return severities.find(item => item === normalized) ?? '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)); const headers = new Set(headersInput.map(normalizedHeader));
if (detailHeaders.every(header => headers.has(header))) return 'detail'; 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 (summaryHeaders.every(header => headers.has(header))) return 'summary';
if (inventoryHeaders.every(header => headers.has(header)) && !headers.has('cve')) return 'inventory';
return undefined; return undefined;
} }
@ -190,6 +209,8 @@ function identitySeed(row: DefenderSourceRow): IdentitySeed {
const displayName = text(value(row, 'Name')); const displayName = text(value(row, 'Name'));
const ip = text(value(row, 'IP')); const ip = text(value(row, 'IP'));
const site = text(value(row, 'Site')); 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); 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' }; 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)); const ipValue = [ip, displayName].find(item => isIpv4(item) || isIpv6(item));
@ -247,9 +268,11 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
const diagnostics: DefenderDiagnostic[] = []; const diagnostics: DefenderDiagnostic[] = [];
const summaryTables = tables.filter(table => defenderTableRole(table.headers) === 'summary'); const summaryTables = tables.filter(table => defenderTableRole(table.headers) === 'summary');
const detailTables = tables.filter(table => defenderTableRole(table.headers) === 'detail'); 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 summaryRows = summaryTables.flatMap(table => table.rows);
const detailRows = detailTables.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); const identities = identityKeys(allRows, diagnostics);
for (const row of summaryRows) { for (const row of summaryRows) {
@ -302,7 +325,9 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
const severitySource = text(value(row, 'Severity')); const severitySource = text(value(row, 'Severity'));
const additionalSourceFields: Record<string, JsonValue> = {}; const additionalSourceFields: Record<string, JsonValue> = {};
for (const [label, fieldValue] of Object.entries(row.fields)) { 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 [{ return [{
id: `observation-${row.tableIndex}-${row.rowIndex}`, id: `observation-${row.tableIndex}-${row.rowIndex}`,
@ -386,27 +411,35 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
const occurrenceByDevice = new Map<string, DefenderOccurrence[]>(); const occurrenceByDevice = new Map<string, DefenderOccurrence[]>();
for (const occurrence of occurrences) occurrenceByDevice.set(occurrence.deviceId, [...(occurrenceByDevice.get(occurrence.deviceId) ?? []), occurrence]); 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 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 devices: DefenderDevice[] = [...deviceIds].map(id => {
const summaryGroup = summaryByDevice.get(id) ?? []; const summaryGroup = summaryByDevice.get(id) ?? [];
const detailGroup = occurrenceByDevice.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 identity = identities.get(representativeRow)!;
const summaryRow = summaryGroup[0]; const summaryRow = summaryGroup[0];
const detailCounts = emptyCounts(); const detailCounts = emptyCounts();
for (const occurrence of detailGroup) if (occurrence.severity !== 'unknown') detailCounts[occurrence.severity] += 1; 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 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 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 { return {
id, id,
identityMethod: identity.method, identityMethod: identity.method,
identityConfidence: identity.confidence, identityConfidence: identity.confidence,
identityProvenance: identity.provenance, identityProvenance: identity.provenance,
displayName: text(value(representativeRow, 'Name')), displayName: firstValue('Name'),
ip: text(value(representativeRow, 'IP')), ip: firstValue('IP'),
site: text(value(representativeRow, 'Site')), site: firstValue('Site'),
sensors: [...new Set(sourceRows.map(row => text(value(row, 'Sensor'))).filter(Boolean))].sort(), sensors: [...new Set(sourceRows.map(row => text(value(row, 'Sensor'))).filter(Boolean))].sort(),
totalCves: summaryRow ? numeric(value(summaryRow, 'TotalCVEs')) : undefined, totalCves: summaryRow ? numeric(value(summaryRow, 'TotalCVEs')) : undefined,
severity: measures, severity: measures,
@ -454,10 +487,12 @@ export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]):
}).sort((left, right) => left.name.localeCompare(right.name)); }).sort((left, right) => left.name.localeCompare(right.name));
return { return {
available: summaryTables.length > 0 || detailTables.length > 0, available: summaryTables.length > 0 || detailTables.length > 0 || inventoryTables.length > 0,
summaryTableCount: summaryTables.length, summaryTableCount: summaryTables.length,
detailTableCount: detailTables.length, detailTableCount: detailTables.length,
inventoryTableCount: inventoryTables.length,
summaryRowCount: summaryRows.length, summaryRowCount: summaryRows.length,
inventoryRowCount: inventoryRows.length,
devices, devices,
cves, cves,
occurrences, occurrences,

View File

@ -25,7 +25,8 @@ export interface ProcessingVersionManifest extends ToolProcessingContract {
export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] = [ 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: '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.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.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.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 }, { 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

@ -67,7 +67,7 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article> <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> <article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
</section> </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"> <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>)} {([['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> </nav>

View File

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

View File

@ -5,18 +5,21 @@ export const defenderForIotDefinition: ToolProfileDefinition = {
schemaVersion: 1, schemaVersion: 1,
id: 'defender-for-iot-vulnerabilities', id: 'defender-for-iot-vulnerabilities',
name: 'Microsoft Defender for IoT', name: 'Microsoft Defender for IoT',
version: '1.1.0', version: '1.2.0',
processing: { processing: {
mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking',
reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'] reimportRequired: true, affectedDimensions: ['recognition', 'extraction', 'finding-identity', 'evidence']
}, },
sourceSignals: [ sourceSignals: [
{ scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' }, { 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: /^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: /^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: /^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: { tableRules: {
preferredNames: [ 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', () => { 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 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.normalizationVersion = '1.1.0';
current.processedWith.presentationVersion = '1.0.0'; current.processedWith.presentationVersion = '1.0.0';
current.processedWith.compatibilityKey = 'defender-for-iot-vulnerabilities-v2'; current.processedWith.compatibilityKey = 'defender-for-iot-vulnerabilities-v2';
expect(processingReadiness('microsoft-defender-for-iot', legacy.processedWith)).toBe('incompatible'); 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(processingReadiness('microsoft-defender-for-iot', current.processedWith)).toBe('current');
expect(resolvedCompatibilityKey('microsoft-defender-for-iot', current.processedWith)) expect(resolvedCompatibilityKey('microsoft-defender-for-iot', current.processedWith))
.toBe('defender-for-iot-vulnerabilities-v2'); .toBe('defender-for-iot-vulnerabilities-v2');

View File

@ -126,7 +126,7 @@ describe('Datahub client processing readiness', () => {
it('assesses the stored profile contract independently from the detected tool identity', () => { it('assesses the stored profile contract independently from the detected tool identity', () => {
const defender = withDetectedTool( 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' 'microsoft-defender-for-iot'
); );
defender.processedWith.mappingVersion = '1.0.0'; 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 { importProfiles } from '../../src/profiles/profile-catalog';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow'; import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset'; 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 { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness';
import { createScannerUnifiedResult } from '../../src/domain/unified-result'; 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'); 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 () => { 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([
@ -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 }); 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.profileId).toBe('defender-for-iot-vulnerabilities');
expect(imported.profileVersion).toBe('1.1.0'); expect(imported.profileVersion).toBe('1.2.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.1.0' });
// Two devices share one CVE: the Work view must show a single aggregated finding, not one row per device. // 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(3); 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' }, detectedTool: { id: 'defender-for-iot-vulnerabilities' },
processedWith: { processedWith: {
profileId: 'defender-for-iot-vulnerabilities', profileId: 'defender-for-iot-vulnerabilities',
profileVersion: '1.1.0', profileVersion: '1.2.0',
normalizationVersion: '1.1.0', normalizationVersion: '1.1.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v2' compatibilityKey: 'defender-for-iot-vulnerabilities-v2'
} }