From a12529e39e1d7b7421bd2697d2092e40d5bc7dc2 Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 2 Oct 2026 12:12:18 +0200 Subject: [PATCH] feat(defender-for-iot): add dataset model, source extractor, and profile on Michael's base Adapted pick of 7d3ef5f. The profile declares the now mandatory securityScoring, priorityBasis and timeslotComparison. Client Info adds Defender as a third tab beside Overview and AI Overview, and a Defender-only timeslot starts in the Defender view. recordTitleFor only replaces the title candidate; the Maester and CIS title rules stay. Left out: the .gitignore entry, --tile-inset, the stored-profile precedence Michael's fileProfile already has, and the old processing version assertion. --- docs/IMPLEMENTATION_PLAN.md | 1 + docs/TOOL_PROFILES.md | 5 +- src/domain/defender-for-iot.ts | 469 ++++++++++++++++++ src/domain/processing-compatibility.ts | 19 +- src/features/dashboard/DashboardPanel.tsx | 21 +- src/features/dataset-viewer/DatasetViewer.tsx | 3 +- 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 | 2 +- .../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 | 69 +++ .../defender-for-iot-profile.ts | 4 + src/profiles/profile-catalog.ts | 2 + src/profiles/profile-definition-catalog.ts | 2 + src/styles/ai-overview.css | 5 - src/styles/defender-for-iot.css | 45 ++ tests/domain/processing-compatibility.test.ts | 7 + tests/features/ai-overview-panel.test.tsx | 2 +- .../datahub-processing-readiness.test.ts | 13 + 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 +++++++ 28 files changed, 1269 insertions(+), 22 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/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 7dd13a7..5a9fec7 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4462,6 +4462,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/7/13A: `7d3ef5f` (Microsoft Defender for IoT, Grundlage) auf Michaels Basis uebernommen. Profil `defender-for-iot-vulnerabilities` 1.0.0 mit Manifestzeile, Source Extractor, Domain-Modell, Client-Info-Panel sowie Titel je Datensatzprofil (`recordTitleFor`) in Work und Pathfinder. Angepasst: Das Profil deklariert die auf dieser Basis verpflichtenden `securityScoring` (kein nativer Gesamtscore, mit Begruendung), `priorityBasis` (native CVE-Severity) und `timeslotComparison` (nicht verfuegbar). Client Info fuehrt Defender als dritten Tab neben Overview und AI Overview; ein reiner Defender-Timeslot startet in der Defender-Sicht. `recordTitleFor` ersetzt nur den Titelkandidaten, die Maester-/CIS-Titelregeln bleiben. `canonicalModelsToDataset` laesst zusaetzlich Modelle mit befuellten Quelltabellen zu, das leere All-Records-Blatt bleibt bestehen. Die Kompatibilitaetspruefung bevorzugt den gespeicherten `processedWith.profileId`; der sichtbare Importvertrag steigt auf 1.4.0. Die kopierten `.client-info-tabs`-Regeln sind aus `ai-overview.css` entfernt und liegen nur noch in `defender-for-iot.css`. Nicht uebernommen: `.gitignore`-Eintrag, `--tile-inset`, der bereits vorhandene `fileProfile`-Vorrang und die alte Versionspruefung. Neues Profil, daher kein Reimport bestehender Importe; keine API-, SQL- oder Persistenzmigration. Die Produktversion wird am Ende der Defender-Serie einmal neu bewertet. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 7/13F: Aus `95ec322` die AI-Overview- und ISO-Mapping-Korrekturen uebernommen. Schlaegt eine Regeneration fehl oder laeuft sie noch, liefert der AI-Overview-Service zusaetzlich `previousSuccessfulRun`; das Panel zeigt die letzte gespeicherte Uebersicht mit der Fehlermeldung weiter, `Stale` gilt nur fuer einen erfolgreichen, veralteten Lauf. Ein gespeichertes ISO-Mapping bleibt lesbar, wenn Qdrant oder der Embeddingdienst nicht erreichbar ist; die Generierung bleibt dann deaktiviert. Synthetische Service- und Paneltests. Die Defender-Geraete-Risikoakzeptanz desselben Commits folgt mit den Defender-Commits. | | 2026-10-02 | Phase 7/13F: Aus `3f6259d` nur den Stale-State-Schutz uebernommen. `useAiOverview` und `useRegulatoryMapping` binden jede Antwort an ihren Anfrage-Scope (Client, Timeslot bzw. Result-DB-Zeile); eine verspaetete Antwort einer frueheren Auswahl wird verworfen. Zwei synthetische Interaktionstests. Die gemeinsame Result-Fenster-Farbpalette, die Entfernung des Pfadverlaufs in `MitreGraphView` und die Defender-Stile desselben Commits wurden auf Nutzerentscheidung nicht uebernommen. | | 2026-10-02 | Phase 13F, ausdruecklicher Nutzerauftrag: ISO-Control-Mapping auf GPO-Baselinevergleiche erweitert. `policy-evaluation`-Zeilen werden wie Scannerfindings gemappt (Einzelmapping im Finding Reader und gefiltertes Result-DB-Batch); Query und Mapperkontext entstehen aus Titel, Guidance und Empfehlung nach der Result-DB-Disclosure-Projektion, der Kundenwert bleibt zurueckgehalten. Globaler Regelschluessel `GPOs:`, daher teilen alle Baselinepakete einer Einstellung ein Mapping. Work-GPO-Zeilen tragen dafuer `policyComparisonId` ihres aktuellen Vergleichs; historische Vergleiche bieten kein Mapping. Rohe GPO- und Environment-Observations bleiben ausgeschlossen (Batchanzeige `Excluded context rows`). Retrievalversion auf Nutzerentscheidung unveraendert; die fruehere Festlegung `nur Scanner-Findings` ist damit fuer GPO-Vergleiche aufgehoben. Der Commit `18c6180` (Hybrid-/Finding-only-Retrieval 1.2-1.6) wurde auf Nutzerentscheidung nicht uebernommen; es gilt weiter das einfache Dense-Retrieval. Synthetische Tests fuer Querygrenze, Einzel- und Batchmapping sowie die Work-Zeilenidentitaet; kein realer Mappinglauf gegen Qdrant/LiteLLM. | 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 50dbc41..90198fe 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 }, @@ -46,6 +47,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)); @@ -53,16 +58,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 5c90e9c..1adfa94 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -53,6 +53,9 @@ import { AssessmentFactsExplanation } from './AssessmentFactsExplanation'; export { dashboardResultState } from './dashboard-result-state'; import { AiOverviewPanel, useAiOverview } from './AiOverviewPanel'; +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'; const assessorApi = new AssessorApiClient(); export { dashboardDomainAssignment, dashboardDomainRecords } from './security-domains'; @@ -1165,14 +1168,18 @@ export function dashboardAnalysis( export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { const app = useApp(); const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app; - const [clientInfoView, setClientInfoView] = useState<'overview' | 'ai'>('overview'); + const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'ai'>('overview'); + const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]); + const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID; const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds); const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []); const showAiOverview = Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run); useEffect(() => { - if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView('overview'); - }, [clientInfoView, showAiOverview]); + if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender'); + else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview'); + else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview'); + }, [clientInfoView, hasDefender, pureDefender, showAiOverview]); const gpoInventory = useGpoInventory(app.targetProjectId); const sourceFilter = app.facetFilters['provenance:source-scan']; const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter); @@ -1256,8 +1263,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { if (!dataset || !activeSheet) return app.targetProjectId ?

Client Info

: ; - const clientInfoViews: Array = [ - ['overview', 'Overview'] as const, + const clientInfoViews: Array = [ + ...(pureDefender ? [] : [['overview', 'Overview'] as const]), + ...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []), ...(showAiOverview ? [['ai', 'AI Overview'] as const] : []) ]; const clientInfoTabs = clientInfoViews.length > 1 ? : undefined; if (clientInfoView === 'ai' && showAiOverview) return ; + if (hasDefender && (clientInfoView === 'defender' || pureDefender)) { + return ; + } const { total, statusFacet, severityFacet, sourceFacet, tools, reports, diff --git a/src/features/dataset-viewer/DatasetViewer.tsx b/src/features/dataset-viewer/DatasetViewer.tsx index 5682f1e..068a86c 100644 --- a/src/features/dataset-viewer/DatasetViewer.tsx +++ b/src/features/dataset-viewer/DatasetViewer.tsx @@ -33,6 +33,7 @@ import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useVirtualRows } from '../../hooks/use-virtual-rows'; import { useFloatingWindowLayer } from '../../components/use-floating-window-layer'; import { toolIdForRecord } from '../../importers/status-normalization'; +import { recordTitleFor } from './record-title'; /** * Geschaetzte Zeilenhoehe fuer die Fenster-Berechnung der Trefferliste @@ -224,7 +225,7 @@ export function DatasetViewer() { if (!dataset || !activeSheet) return ; const recordTitle = (record: FindingRecord) => { - const candidate = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title; + const candidate = recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset.profileId); const recordToolId = toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId); if (recordToolId === 'maester') return maesterFindingTitle(record, candidate); // CIS headings name their benchmark section instead of the rule number, which stays in Source Rule ID. 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