From 0cf3a632ccb53dcb248f938b52fca4306f952c82 Mon Sep 17 00:00:00 2001 From: Binh Date: Thu, 10 Sep 2026 09:17:20 +0200 Subject: [PATCH] feat(defender-for-iot): add dataset model, source extractor, and profile definition --- .gitignore | 1 + docs/IMPLEMENTATION_PLAN.md | 3 + docs/TOOL_PROFILES.md | 5 +- src/domain/defender-for-iot.ts | 469 ++++++++++++++++++ src/domain/processing-compatibility.ts | 19 +- src/features/dashboard/DashboardPanel.tsx | 19 + src/features/dataset-viewer/DatasetViewer.tsx | 11 +- src/features/dataset-viewer/record-title.ts | 20 + .../DefenderVulnerabilityPanel.tsx | 106 ++++ .../defender-dataset-model.ts | 83 ++++ src/features/mitre-graph/mitre-graph.ts | 3 +- .../canonical-model-to-dataset.ts | 4 +- .../project-import/datahub-scan-readiness.ts | 10 +- .../defender-for-iot-source-extractor.ts | 119 +++++ src/importers/source-extractors.ts | 2 + src/main.tsx | 1 + .../defender-for-iot-definition.ts | 54 ++ .../defender-for-iot-profile.ts | 4 + src/profiles/profile-catalog.ts | 2 + src/profiles/profile-definition-catalog.ts | 2 + src/styles/defender-for-iot.css | 45 ++ tests/domain/processing-compatibility.test.ts | 7 + .../datahub-processing-readiness.test.ts | 14 + tests/features/datahub-scan-readiness.test.ts | 12 + tests/features/defender-dashboard.test.ts | 60 +++ tests/features/record-title.test.ts | 28 ++ tests/importers/defender-for-iot.test.ts | 178 +++++++ 27 files changed, 1269 insertions(+), 12 deletions(-) create mode 100644 src/domain/defender-for-iot.ts create mode 100644 src/features/dataset-viewer/record-title.ts create mode 100644 src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx create mode 100644 src/features/defender-for-iot/defender-dataset-model.ts create mode 100644 src/importers/defender-for-iot-source-extractor.ts create mode 100644 src/profiles/defender-for-iot/defender-for-iot-definition.ts create mode 100644 src/profiles/defender-for-iot/defender-for-iot-profile.ts create mode 100644 src/styles/defender-for-iot.css create mode 100644 tests/features/defender-dashboard.test.ts create mode 100644 tests/features/record-title.test.ts create mode 100644 tests/importers/defender-for-iot.test.ts diff --git a/.gitignore b/.gitignore index 36e6b37..f7a6652 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ src-tauri/gen/ vite.config.js vite.config.d.ts *.png +artifacts/objects/ .env.postgres .assessor-data/ diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 55860f0..1a63852 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2580,6 +2580,9 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. | Datum | Änderung | +| 2026-09-10 | Phasen 7/13A fuer gemischte Defender-Timeslots korrigiert: Work bestimmt den Titel jedes Datensatzes anhand seiner Scannerprovenienz und des deklarativen Titel-Headers, sodass Defender-Vorkommen im gemeinsamen All-Records-Blatt die CVE statt Device-Name oder IP zeigen. Client Info behaelt bei mehreren Scannern die allgemeine Uebersicht und bietet Defender zusaetzlich als eigenen Tab mit den vorhandenen CVE-, Device- und Site-Sichten an. 19 fokussierte Dashboard-, Titel- und Graphregressionen erfolgreich. Keine API-, SQL-, Profil- oder Persistenzmigration; Phasen 7 und 13A bleiben abgeschlossen. | +| 2026-09-10 | Phase-13A-Verarbeitungsvertrag fuer Defender-Importe korrigiert: Die Kompatibilitaetspruefung verwendet nun den tatsaechlich persistierten `processedWith.profileId` statt einer eventuell abweichenden erkannten Werkzeug-ID. Dadurch werden registrierte Defender-Profile 1.0.0 als aktuell bewertet; unbekannte Profil-/Versionspaare bleiben fail closed. Die Korrektur ist read-only fuer vorhandene Importdaten und benoetigt keinen Reimport oder Datenbankeingriff. 17 fokussierte Import-/Kompatibilitaetstests sowie der Gesamtlauf mit 766 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich; Phase 13A bleibt abgeschlossen. | +| 2026-09-10 | Microsoft Defender for IoT vulnerability imports and views umgesetzt: Profil und Source Extractor 1.0.0 erweitern die generische Excel-/CSV-Pipeline; Device-Identitaeten, CVE-Definitionen, Device-mal-CVE-Vorkommen, rohe Sensorbeobachtungen und Aggregate-only-Zahlen bleiben getrennt. Client Info bietet CVE-, Device- und Site-Sichten mit Evidenz- und Reconciliation-Drill-down. Summary-only-Quellen bleiben ohne erfundene Findings viewable. 33 fokussierte synthetische Tests sowie 764 Standardtests, 8 uebersprungene optionale PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzmigration; Phasen 6, 7 und 13A bleiben abgeschlossen. | | 2026-09-16 | Phase 7/13B: Registry.pol-Zustandsregeln persistent verankert und mit Evaluator 1.9.0 umgesetzt. Delete-value/Delete-all/Create-key und typisierte Leerwerte sind vergleichbar; Schluesselziele beziehen konfigurierte Kindwerte samt Evidenzreferenzen ein. Gewoehnliche fehlende Empfehlungen bleiben Not configured, waehrend fehlende ausdruecklich verlangte Schluessel- oder Leerwertpraesenz Fail ist. Historie bis 1.8.0 bleibt unveraendert und wird nur ausdruecklich neu ausgewertet. 32 fokussierte Tests und exakte Work-Statusregressionen, 1.280 bestandene Gesamttests, acht optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Kein Reimport, Processing-Manifest- oder SQL-Schemawechsel; keine reale Bestandsneuauswertung. | | 2026-09-15 | Phase 7: Work verwendet beim normalen Einstieg und bei neu initialisierten Projekt-, Timeslot-, Blatt-, Import- und Reset-Zustaenden immer `Deviation`. Die bisherige automatische GPO-Ausnahme `All` ist durch die ausdrueckliche Nutzerentscheidung ersetzt. Bereits gesetzte Filter aus Client Info, Pathfinder und anderen Drilldowns bleiben beim Wechsel nach Work erhalten; explizit gesetztes `All` bleibt damit ebenfalls moeglich. Permanente synthetische Navigations- und GPO-Regressionen, 1.270 bestandene Gesamttests, acht optionale PostgreSQL-Tests uebersprungen, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. Keine Daten-, Verarbeitungs- oder Reimportaenderung. | | 2026-09-15 | Phase 7/13B: Irrefuehrende manuelle Aufforderung am partiellen GPO-Import entfernt. Der gelbe technische Zustand lautet wieder `Limited analysis`; er ist keine Aufgabe, kein automatisches Manage-Signal und keine Aufforderung zur Einzelvalidierung. Damit wird die bindende Produktentscheidung vom 2026-09-06 wiederhergestellt und die zwischenzeitliche Formulierung `Partial · review required` ausdruecklich ersetzt. Synthetischer Rendergegenfall, 17 fokussierte Tests, 1.268 bestandene Gesamttests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich; acht optionale PostgreSQL-Tests uebersprungen. Keine Daten- oder Verarbeitungssemantikaenderung. | diff --git a/docs/TOOL_PROFILES.md b/docs/TOOL_PROFILES.md index 520547f..83a3305 100644 --- a/docs/TOOL_PROFILES.md +++ b/docs/TOOL_PROFILES.md @@ -1,6 +1,6 @@ # Deklarative Toolprofile -Stand: 2026-07-17 +Stand: 2026-09-10 Profilschema: 1 ## Zweck @@ -32,6 +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 | 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. @@ -64,6 +65,8 @@ 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. + ## Darstellung von Textfeldern Verbindlich für alle Werkzeuge und alle Ansichten: **kein Aufrufer formatiert einen Feldwert selbst.** Jeder werkzeuggelieferte Fließtext wird über `RichTextValue` ausgegeben, das die Auswertung an `src/shared/markdown.ts` delegiert. Ein neues Profil braucht dafür keine eigene Regel. diff --git a/src/domain/defender-for-iot.ts b/src/domain/defender-for-iot.ts new file mode 100644 index 0000000..8deb617 --- /dev/null +++ b/src/domain/defender-for-iot.ts @@ -0,0 +1,469 @@ +import type { JsonValue } from './canonical-model'; + +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 interface DefenderSourceRow { + tableIndex: number; + rowIndex: number; + fields: Record; +} + +export interface DefenderSourceTable { + tableIndex: number; + name: string; + headers: string[]; + rows: DefenderSourceRow[]; +} + +export interface DefenderDiagnostic { + severity: 'info' | 'warning'; + code: string; + message: string; + tableIndex?: number; + rowIndex?: number; + details?: Record; +} + +export interface DefenderObservation { + id: string; + deviceId: string; + cve: string; + name: string; + ip: string; + site: string; + sensor: string; + severity: DefenderSeverity | 'unknown'; + severitySource: string; + description: string; + additionalSourceFields: Record; + tableIndex: number; + rowIndex: number; + sourceFields: Record; +} + +export interface DefenderOccurrence { + id: string; + deviceId: string; + cve: string; + severity: DefenderSeverity | 'unknown'; + description: string; + sensors: string[]; + sites: string[]; + observations: DefenderObservation[]; +} + +export interface DefenderSeverityMeasure { + detail: number; + summary?: number; + aggregateOnly: number; + state: DefenderDetailState; +} + +export interface DefenderDevice { + id: string; + identityMethod: DefenderIdentityMethod; + identityConfidence: 'high' | 'medium' | 'low'; + identityProvenance: string; + displayName: string; + ip: string; + site: string; + sensors: string[]; + totalCves?: number; + severity: Record; + occurrenceIds: string[]; + summaryRowCount: number; + summaryConflict: boolean; +} + +export interface DefenderCve { + id: string; + severity: DefenderSeverity | 'unknown'; + description: string; + deviceIds: string[]; + sites: string[]; + sensors: string[]; + observationCount: number; + conflictingSeverity: boolean; + conflictingDescription: boolean; +} + +export interface DefenderSite { + id: string; + name: string; + assetCount: number; + uniqueCveCount: number; + affectedDeviceCount: number; + occurrences: Record; + aggregateOnly: Record; + rawObservationCount: number; + deviceIds: string[]; + detailCompleteness: string; +} + +export interface DefenderVulnerabilityModel { + available: boolean; + summaryTableCount: number; + detailTableCount: number; + summaryRowCount: number; + devices: DefenderDevice[]; + cves: DefenderCve[]; + occurrences: DefenderOccurrence[]; + observations: DefenderObservation[]; + sites: DefenderSite[]; + aggregateOnlyCount: number; + diagnostics: DefenderDiagnostic[]; +} + +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']; + +function text(value: JsonValue | undefined): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} + +function normalizedHeader(value: string): string { + return value.trim().toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, ''); +} + +function normalizedText(value: string): string { + return value.trim().toLocaleLowerCase('en-US').replace(/\s+/g, ' '); +} + +function value(row: DefenderSourceRow, header: string): JsonValue | undefined { + const target = normalizedHeader(header); + const match = Object.keys(row.fields).find(key => normalizedHeader(key) === target); + return match === undefined ? undefined : row.fields[match]; +} + +function numeric(value: JsonValue | undefined): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value)); + const parsed = Number(text(value)); + return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : undefined; +} + +function isMac(value: string): boolean { + return /^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value) + || /^[0-9a-f]{12}$/i.test(value); +} + +function isIpv4(value: string): boolean { + const parts = value.split('.'); + return parts.length === 4 && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255); +} + +function isIpv6(value: string): boolean { + return value.includes(':') && /^[0-9a-f:]+$/i.test(value) + && value.split(':').filter(Boolean).length <= 8; +} + +function severity(value: string): DefenderSeverity | 'unknown' { + const normalized = normalizedText(value); + return severities.find(item => item === normalized) ?? 'unknown'; +} + +export function defenderTableRole(headersInput: string[]): 'summary' | 'detail' | undefined { + const headers = new Set(headersInput.map(normalizedHeader)); + if (detailHeaders.every(header => headers.has(header))) return 'detail'; + if (summaryHeaders.every(header => headers.has(header))) return 'summary'; + return undefined; +} + +interface IdentitySeed { + method: DefenderIdentityMethod; + base: string; + site: string; + displayName: string; + ip: string; + provenance: string; + confidence: 'high' | 'medium' | 'low'; +} + +function identitySeed(row: DefenderSourceRow): IdentitySeed { + const displayName = text(value(row, 'Name')); + const ip = text(value(row, 'IP')); + const site = text(value(row, 'Site')); + 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)); + if (ipValue) return { method: 'ip', base: normalizedText(ipValue), site, displayName, ip, provenance: ipValue === ip ? 'valid address from IP' : 'valid address from Name', confidence: 'medium' }; + if (displayName && site) return { method: 'name', base: normalizedText(displayName), site, displayName, ip, provenance: 'normalized Name within Site', confidence: 'medium' }; + return { method: 'unresolved', base: `${row.tableIndex}:${row.rowIndex}`, site, displayName, ip, provenance: 'insufficient stable identity fields', confidence: 'low' }; +} + +function identityKeys(rows: DefenderSourceRow[], diagnostics: DefenderDiagnostic[]): Map { + const seeds = rows.map(row => ({ row, seed: identitySeed(row) })); + const sitesByBase = new Map>(); + for (const { seed } of seeds) { + if (seed.method === 'unresolved') continue; + const key = `${seed.method}:${seed.base}`; + const sites = sitesByBase.get(key) ?? new Set(); + if (seed.site) sites.add(normalizedText(seed.site)); + sitesByBase.set(key, sites); + } + const warned = new Set(); + const result = new Map(); + for (const { row, seed } of seeds) { + const collisionKey = `${seed.method}:${seed.base}`; + const reusedAcrossSites = (sitesByBase.get(collisionKey)?.size ?? 0) > 1; + const siteScoped = seed.method === 'name' || reusedAcrossSites; + const id = seed.method === 'unresolved' + ? `unresolved:${seed.base}` + : `${collisionKey}${siteScoped ? `|site:${normalizedText(seed.site) || 'missing'}` : ''}`; + result.set(row, { ...seed, id }); + if (seed.method === 'unresolved') diagnostics.push({ + severity: 'warning', code: 'defender-device-identity-unresolved', + message: 'A source row has no unambiguous MAC, IP, or site-scoped device name.', + tableIndex: row.tableIndex, rowIndex: row.rowIndex + }); + if (reusedAcrossSites && !warned.has(collisionKey)) { + warned.add(collisionKey); + diagnostics.push({ severity: 'warning', code: 'defender-device-identity-site-scoped', message: 'A device identifier is reused across sites and was kept site-scoped.', details: { identityMethod: seed.method } }); + } + } + return result; +} + +function emptyCounts(): Record { + return { critical: 0, high: 0, medium: 0, low: 0 }; +} + +function detailState(summary: number | undefined, detail: number, severityHasDetail: boolean): DefenderSeverityMeasure { + if (summary === undefined) return { detail, aggregateOnly: 0, state: detail > 0 ? 'detail-only' : 'unavailable' }; + if (summary === 0 && detail === 0) return { detail, summary, aggregateOnly: 0, state: 'zero' }; + if (summary > 0 && detail === 0) return { detail, summary, aggregateOnly: summary, state: severityHasDetail ? 'mismatch' : 'aggregate-only' }; + if (summary === detail) return { detail, summary, aggregateOnly: 0, state: 'complete' }; + return { detail, summary, aggregateOnly: Math.max(0, summary - detail), state: 'mismatch' }; +} + +export function buildDefenderVulnerabilityModel(tables: DefenderSourceTable[]): DefenderVulnerabilityModel { + const diagnostics: DefenderDiagnostic[] = []; + const summaryTables = tables.filter(table => defenderTableRole(table.headers) === 'summary'); + const detailTables = tables.filter(table => defenderTableRole(table.headers) === 'detail'); + const summaryRows = summaryTables.flatMap(table => table.rows); + const detailRows = detailTables.flatMap(table => table.rows); + const allRows = [...summaryRows, ...detailRows]; + const identities = identityKeys(allRows, diagnostics); + + for (const row of summaryRows) { + const total = numeric(value(row, 'TotalCVEs')); + const components = severities.map(item => numeric(value(row, item))); + if (total !== undefined && components.every(item => item !== undefined) + && total !== components.reduce((sum, item) => sum + (item ?? 0), 0)) { + diagnostics.push({ + severity: 'warning', code: 'defender-summary-total-mismatch', + message: 'TotalCVEs does not equal Critical + High + Medium + Low for an asset summary row.', + tableIndex: row.tableIndex, rowIndex: row.rowIndex, + details: { total, componentTotal: components.reduce((sum, item) => sum + (item ?? 0), 0) } + }); + } + } + + const rowsByIdentity = new Map(); + for (const row of allRows) { + const id = identities.get(row)!.id; + rowsByIdentity.set(id, [...(rowsByIdentity.get(id) ?? []), row]); + } + for (const [deviceId, rows] of rowsByIdentity) { + const names = new Set(rows.map(row => normalizedText(text(value(row, 'Name')))).filter(Boolean)); + const ips = new Set(rows.map(row => normalizedText(text(value(row, 'IP')))).filter(Boolean)); + if (names.size > 1 || ips.size > 1) diagnostics.push({ + severity: 'warning', code: 'defender-device-identity-ambiguous', + message: 'Rows grouped under one normalized device contain conflicting source identity values and require review.', + details: { deviceId, nameVariantCount: names.size, ipVariantCount: ips.size } + }); + } + + for (const table of detailTables) { + const nullHeader = table.headers.find(header => normalizedHeader(header) === 'null'); + if (!nullHeader) continue; + const populated = table.rows.filter(row => text(value(row, nullHeader))).length; + if (populated) diagnostics.push({ + severity: 'warning', code: 'defender-null-column-populated', + message: 'The source column named NULL contains values; they were preserved as additional source fields.', + tableIndex: table.tableIndex, details: { populatedRows: populated } + }); + } + + const observations: DefenderObservation[] = detailRows.flatMap(row => { + const cve = text(value(row, 'CVE')).toLocaleUpperCase('en-US'); + const identity = identities.get(row)!; + if (!cve) { + diagnostics.push({ severity: 'warning', code: 'defender-cve-missing', message: 'A detail row without a CVE identifier was preserved as source data but not made actionable.', tableIndex: row.tableIndex, rowIndex: row.rowIndex }); + return []; + } + const severitySource = text(value(row, 'Severity')); + const additionalSourceFields: Record = {}; + for (const [label, fieldValue] of Object.entries(row.fields)) { + if (normalizedHeader(label) === 'null' && text(fieldValue)) additionalSourceFields[label] = fieldValue; + } + return [{ + id: `observation-${row.tableIndex}-${row.rowIndex}`, + deviceId: identity.id, + cve, + name: text(value(row, 'Name')), + ip: text(value(row, 'IP')), + site: text(value(row, 'Site')), + sensor: text(value(row, 'Sensor')), + severity: severity(severitySource), + severitySource, + description: text(value(row, 'Beschreibung')), + additionalSourceFields, + tableIndex: row.tableIndex, + rowIndex: row.rowIndex, + sourceFields: row.fields + }]; + }); + + const duplicateObservationGroups = new Map(); + for (const observation of observations) { + const key = [observation.deviceId, observation.cve, normalizedText(observation.sensor), normalizedText(observation.severitySource), normalizedText(observation.description)].join('|'); + duplicateObservationGroups.set(key, [...(duplicateObservationGroups.get(key) ?? []), observation]); + } + const duplicateRows = [...duplicateObservationGroups.values()].reduce((sum, group) => sum + Math.max(0, group.length - 1), 0); + if (duplicateRows) diagnostics.push({ severity: 'warning', code: 'defender-duplicate-source-observation', message: 'Duplicate source observations were retained but do not increase actionable occurrence counts.', details: { duplicateRows } }); + + const observationGroups = new Map(); + for (const observation of observations) { + const key = `${observation.deviceId}|${observation.cve}`; + observationGroups.set(key, [...(observationGroups.get(key) ?? []), observation]); + } + const occurrences: DefenderOccurrence[] = [...observationGroups.entries()].map(([id, group]) => ({ + id, + deviceId: group[0].deviceId, + cve: group[0].cve, + severity: group.find(item => item.severity !== 'unknown')?.severity ?? 'unknown', + description: group.find(item => item.description)?.description ?? '', + sensors: [...new Set(group.map(item => item.sensor).filter(Boolean))].sort(), + sites: [...new Set(group.map(item => item.site).filter(Boolean))].sort(), + observations: group + })).sort((left, right) => left.cve.localeCompare(right.cve) || left.deviceId.localeCompare(right.deviceId)); + + const cveGroups = new Map(); + for (const occurrence of occurrences) cveGroups.set(occurrence.cve, [...(cveGroups.get(occurrence.cve) ?? []), occurrence]); + const cves: DefenderCve[] = [...cveGroups.entries()].map(([id, group]) => { + const all = group.flatMap(item => item.observations); + const severityValues = [...new Set(all.map(item => item.severitySource).filter(Boolean).map(normalizedText))]; + const descriptions = [...new Set(all.map(item => item.description).filter(Boolean).map(normalizedText))]; + const conflictingSeverity = severityValues.length > 1; + const conflictingDescription = descriptions.length > 1; + if (conflictingSeverity) diagnostics.push({ severity: 'warning', code: 'defender-cve-severity-conflict', message: 'A CVE has conflicting source severity values.', details: { cve: id, variantCount: severityValues.length } }); + if (conflictingDescription) diagnostics.push({ severity: 'warning', code: 'defender-cve-description-conflict', message: 'A CVE has conflicting source descriptions.', details: { cve: id, variantCount: descriptions.length } }); + return { + id, + severity: all.find(item => item.severity !== 'unknown')?.severity ?? 'unknown', + description: all.find(item => item.description)?.description ?? '', + deviceIds: [...new Set(group.map(item => item.deviceId))].sort(), + sites: [...new Set(all.map(item => item.site).filter(Boolean))].sort(), + sensors: [...new Set(all.map(item => item.sensor).filter(Boolean))].sort(), + observationCount: all.length, + conflictingSeverity, + conflictingDescription + }; + }).sort((left, right) => left.id.localeCompare(right.id)); + + const summaryByDevice = new Map(); + for (const row of summaryRows) { + const id = identities.get(row)!.id; + summaryByDevice.set(id, [...(summaryByDevice.get(id) ?? []), row]); + } + for (const [deviceId, rows] of summaryByDevice) { + if (rows.length < 2) continue; + const signatures = new Set(rows.map(row => [numeric(value(row, 'TotalCVEs')), ...severities.map(item => numeric(value(row, item)))].join('|'))); + diagnostics.push({ + severity: 'warning', code: signatures.size > 1 ? 'defender-summary-conflict' : 'defender-summary-duplicate', + message: signatures.size > 1 ? 'A normalized device has conflicting asset-summary rows.' : 'A duplicate asset-summary row was retained without double-counting.', + details: { deviceId, rowCount: rows.length } + }); + } + + const occurrenceByDevice = new Map(); + for (const occurrence of occurrences) occurrenceByDevice.set(occurrence.deviceId, [...(occurrenceByDevice.get(occurrence.deviceId) ?? []), occurrence]); + 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 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 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; + const measures = Object.fromEntries(severities.map(item => [item, detailState(summaryCounts[item], detailCounts[item], severitiesWithDetail.has(item))])) as Record; + const sourceRows = [...summaryGroup, ...detailRows.filter(row => identities.get(row)?.id === id)]; + 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')), + sensors: [...new Set(sourceRows.map(row => text(value(row, 'Sensor'))).filter(Boolean))].sort(), + totalCves: summaryRow ? numeric(value(summaryRow, 'TotalCVEs')) : undefined, + severity: measures, + occurrenceIds: detailGroup.map(item => item.id), + summaryRowCount: summaryGroup.length, + summaryConflict: summaryGroup.length > 1 && new Set(summaryGroup.map(row => [numeric(value(row, 'TotalCVEs')), ...severities.map(item => numeric(value(row, item)))].join('|'))).size > 1 + }; + }).sort((left, right) => left.site.localeCompare(right.site) || left.displayName.localeCompare(right.displayName)); + + for (const device of devices) { + for (const item of severities) { + if (device.severity[item].state === 'mismatch' || device.severity[item].state === 'detail-only') diagnostics.push({ + severity: 'warning', code: 'defender-summary-detail-mismatch', + message: 'A device severity total does not reconcile between summary and detailed occurrences.', + details: { deviceId: device.id, severity: item, summary: device.severity[item].summary ?? null, detail: device.severity[item].detail } + }); + } + } + + const siteNames = new Set(devices.map(device => device.site || 'Site unavailable')); + const sites: DefenderSite[] = [...siteNames].map(name => { + const siteDevices = devices.filter(device => (device.site || 'Site unavailable') === name); + const ids = new Set(siteDevices.map(device => device.id)); + const siteOccurrences = occurrences.filter(item => ids.has(item.deviceId)); + const occurrenceCounts = emptyCounts(); + for (const occurrence of siteOccurrences) if (occurrence.severity !== 'unknown') occurrenceCounts[occurrence.severity] += 1; + const aggregateOnly = emptyCounts(); + for (const device of siteDevices) for (const item of severities) aggregateOnly[item] += device.severity[item].aggregateOnly; + const states = siteDevices.flatMap(device => severities.map(item => device.severity[item].state)); + const detailCompleteness = states.includes('mismatch') ? 'Mismatch' + : states.includes('aggregate-only') ? 'Aggregate-only detail' + : states.includes('unavailable') ? 'Detail unavailable' + : 'Detailed'; + return { + id: normalizedText(name), name, + assetCount: siteDevices.length, + uniqueCveCount: new Set(siteOccurrences.map(item => item.cve)).size, + affectedDeviceCount: siteDevices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length, + occurrences: occurrenceCounts, + aggregateOnly, + rawObservationCount: observations.filter(item => ids.has(item.deviceId)).length, + deviceIds: siteDevices.map(item => item.id), + detailCompleteness + }; + }).sort((left, right) => left.name.localeCompare(right.name)); + + return { + available: summaryTables.length > 0 || detailTables.length > 0, + summaryTableCount: summaryTables.length, + detailTableCount: detailTables.length, + summaryRowCount: summaryRows.length, + devices, + cves, + occurrences, + observations, + sites, + aggregateOnlyCount: devices.reduce((sum, device) => sum + severities.reduce((inner, item) => inner + device.severity[item].aggregateOnly, 0), 0), + diagnostics + }; +} diff --git a/src/domain/processing-compatibility.ts b/src/domain/processing-compatibility.ts index 303011d..a86f67a 100644 --- a/src/domain/processing-compatibility.ts +++ b/src/domain/processing-compatibility.ts @@ -9,7 +9,7 @@ export type ProcessingReadinessStatus = | 'unknown'; /** Globaler, in der Oberflaeche sichtbarer Stand des ASSESSOR-Verarbeitungsvertrags. */ -export const CURRENT_ASSESSOR_PROCESSING_VERSION = '1.3.0'; +export const CURRENT_ASSESSOR_PROCESSING_VERSION = '1.4.0'; export interface ProcessingVersionManifest extends ToolProcessingContract { profileId: string; @@ -24,6 +24,7 @@ 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: 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 }, @@ -44,6 +45,10 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] const manifestKey = (profileId: string, profileVersion: string) => `${profileId.trim().toLocaleLowerCase('en-US')}@${profileVersion.trim()}`; const manifestLookup = new Map(PROCESSING_VERSION_MANIFESTS.map(item => [manifestKey(item.profileId, item.profileVersion), item])); +function recordedProfileId(profileId: string | undefined, processedWith: ProcessingVersion): string | undefined { + return processedWith.profileId?.trim() || profileId?.trim(); +} + export function processingManifest(profileId: string | undefined, profileVersion: string | undefined): ProcessingVersionManifest | undefined { if (!profileId || !profileVersion) return undefined; return manifestLookup.get(manifestKey(profileId, profileVersion)); @@ -51,16 +56,20 @@ export function processingManifest(profileId: string | undefined, profileVersion export function currentProcessingManifest(profileId: string | undefined): ProcessingVersionManifest | undefined { if (!profileId) return undefined; - return PROCESSING_VERSION_MANIFESTS.find(item => item.current && item.profileId === profileId); + const normalized = profileId.trim().toLocaleLowerCase('en-US'); + return PROCESSING_VERSION_MANIFESTS.find(item => item.current + && item.profileId.toLocaleLowerCase('en-US') === normalized); } export function resolvedCompatibilityKey(profileId: string | undefined, processedWith: ProcessingVersion): string | undefined { - return processedWith.compatibilityKey ?? processingManifest(profileId ?? processedWith.profileId, processedWith.profileVersion)?.compatibilityKey; + return processedWith.compatibilityKey + ?? processingManifest(recordedProfileId(profileId, processedWith), processedWith.profileVersion)?.compatibilityKey; } export function processingReadiness(profileId: string | undefined, processedWith: ProcessingVersion): ProcessingReadinessStatus { - const manifest = processingManifest(profileId ?? processedWith.profileId, processedWith.profileVersion); - const current = currentProcessingManifest(profileId ?? processedWith.profileId); + const effectiveProfileId = recordedProfileId(profileId, processedWith); + const manifest = processingManifest(effectiveProfileId, processedWith.profileVersion); + const current = currentProcessingManifest(effectiveProfileId); if (!manifest || !current) return 'unknown'; const recordedKey = processedWith.compatibilityKey ?? manifest.compatibilityKey; if (recordedKey !== current.compatibilityKey) return 'incompatible'; diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index addd719..1ffd8cc 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -17,6 +17,9 @@ import { dashboardSecuritySources } from './dashboard-security-score'; import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains'; import { ScoringMethodology } from './ScoringMethodology'; import { RiskRegister } from './RiskRegister'; +import { DEFENDER_FOR_IOT_PROFILE_ID } from '../../domain/defender-for-iot'; +import { DefenderVulnerabilityPanel } from '../defender-for-iot/DefenderVulnerabilityPanel'; +import { defenderModelForDataset } from '../defender-for-iot/defender-dataset-model'; import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet, type DynamicFacet, type DynamicFacetFilter, type DynamicFacetValue } from '../search-filter/dynamic-facets'; import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl'; import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state'; @@ -1324,11 +1327,26 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { observer?.disconnect(); }; }, [dataset, activeSheet]); + const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender'>('overview'); + const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]); + + useEffect(() => { + if (!hasDefender) setClientInfoView('overview'); + }, [hasDefender]); if (!dataset || !activeSheet) return app.targetProjectId ?

Client Info

: ; + if (dataset.profileId === DEFENDER_FOR_IOT_PROFILE_ID) return ; + const clientInfoTabs = hasDefender ? : undefined; + if (clientInfoView === 'defender' && hasDefender) { + return ; + } + const { total, statusFacet, severityFacet, sourceFacet, tools, reports, statusCount, sourceScores, domains, unclassifiedDomain, totalResult, overallScore @@ -1385,6 +1403,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { return (
+ {clientInfoTabs}
Client:{dataset.fileName}
Assessment:
diff --git a/src/features/dataset-viewer/DatasetViewer.tsx b/src/features/dataset-viewer/DatasetViewer.tsx index 6f0ac9c..49d1dc6 100644 --- a/src/features/dataset-viewer/DatasetViewer.tsx +++ b/src/features/dataset-viewer/DatasetViewer.tsx @@ -27,6 +27,7 @@ import { import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useVirtualRows } from '../../hooks/use-virtual-rows'; import { toolIdForRecord } from '../../importers/status-normalization'; +import { recordTitleFor } from './record-title'; /** * Geschaetzte Zeilenhoehe fuer die Fenster-Berechnung der Trefferliste @@ -132,6 +133,13 @@ export function DatasetViewer() { left: Math.max(0, Math.min(requestedClusterPos.left, maxClusterLeft)), top: Math.max(minClusterTop, Math.min(requestedClusterPos.top, maxClusterTop)) }; + const recordTitles = useMemo(() => { + if (!activeSheet) return new Map(); + return new Map(activeSheet.analysis.records.map(record => [ + record.id, + recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset?.profileId) + ])); + }, [activeSheet, dataset?.profileId, titleColumn]); const resultColorsByRecord = useMemo(() => new Map(resultWindows.map(item => [item.recordId, item.color])), [resultWindows]); // Kacheln: die Spaltenzahl treibt sowohl das CSS-Grid als auch die @@ -228,8 +236,7 @@ export function DatasetViewer() { if (!dataset || !activeSheet) return ; - const recordTitle = (record: FindingRecord) => - (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title; + const recordTitle = (record: FindingRecord) => recordTitles.get(record.id) ?? record.title; const selected = records.find(record => record.id === selectedRecordId) ?? records[0]; const toolId = activeSheet.toolId ?? dataset.profileId; const linksFor = (record: FindingRecord) => recordResultSheets(record, dataset.sheets, activeSheet.id, toolId); diff --git a/src/features/dataset-viewer/record-title.ts b/src/features/dataset-viewer/record-title.ts new file mode 100644 index 0000000..afb8e47 --- /dev/null +++ b/src/features/dataset-viewer/record-title.ts @@ -0,0 +1,20 @@ +import type { FindingRecord } from '../../domain/finding'; +import { toolIdForRecord } from '../../importers/status-normalization'; +import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog'; + +function matches(pattern: RegExp, value: string): boolean { + pattern.lastIndex = 0; + return pattern.test(value.trim()); +} + +/** Resolves a title with the individual record's profile in mixed project sheets. */ +export function recordTitleFor(record: FindingRecord, selectedColumn = '', fallbackToolId?: string): string { + const toolId = toolIdForRecord(record, fallbackToolId); + const titlePatterns = toolProfileDefinitions.find(definition => definition.id === toolId)?.presentation.titleHeaders ?? []; + const profileTitle = titlePatterns + .map(pattern => record.fields.find(field => field.value && matches(pattern, field.label))?.value) + .find(Boolean); + return profileTitle + ?? (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value) + ?? record.title; +} diff --git a/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx new file mode 100644 index 0000000..0c312a8 --- /dev/null +++ b/src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx @@ -0,0 +1,106 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import type { DefenderDevice, DefenderSeverity, DefenderSeverityMeasure } from '../../domain/defender-for-iot'; +import type { ImportedDataset } from '../../domain/dataset'; +import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl'; +import { defenderModelForDataset } from './defender-dataset-model'; + +type DefenderView = 'cves' | 'devices' | 'sites'; + +const severities: DefenderSeverity[] = ['critical', 'high', 'medium', 'low']; + +function label(value: string): string { + return value ? value.charAt(0).toLocaleUpperCase('en-US') + value.slice(1) : 'Unknown'; +} + +function Count({ value }: { value: number }) { + return <>{value.toLocaleString('en-US')}; +} + +function Measure({ measure }: { measure: DefenderSeverityMeasure }) { + if (measure.state === 'unavailable') return Unavailable; + if (measure.state === 'aggregate-only') return aggregate-only; + if (measure.state === 'mismatch') return detailed of {measure.summary?.toLocaleString('en-US') ?? 'unavailable'} summary; + if (measure.state === 'detail-only') return detail only; + return ; +} + +function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' }) { + return