From 7d3ef5f5ae10bbac1457fc31b6806bdc75f037b6 Mon Sep 17 00:00:00 2001 From: Binh Date: Thu, 10 Sep 2026 09:17:20 +0200 Subject: [PATCH 01/33] feat(defender-for-iot): add dataset model, source extractor, and profile definition --- .gitignore | 1 + docs/IMPLEMENTATION_PLAN.md | 22 + 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 | 5 +- 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 | 8 +- .../datahub-processing-readiness.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 | 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 ++ src/styles/ui-tokens.css | 1 + tests/app/app-version.test.ts | 2 +- 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 +++++++ 30 files changed, 1287 insertions(+), 16 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 65dcec1..600072d 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -358,6 +358,11 @@ Verifizierter Abschlussstand vom 2026-07-17: - Profilschema, Versionierung und Erweiterungsablauf sind in `docs/TOOL_PROFILES.md` dokumentiert. - 29 Testdateien mit 74 Tests, Local-only- und Architekturprüfung sowie Produktionsbuild laufen erfolgreich. +Verifizierte Erweiterung vom 2026-09-10: + +- Das versionierte Profil `defender-for-iot-vulnerabilities` 1.0.0 erkennt kombinierte sowie einzelne Asset-Summary- und CVE-Detailtabellen ueber Strukturmerkmale und nutzt dieselbe Profilfabrik wie die bestehenden Scanner. +- CVE, Beschreibung, Severity, normalisierte Device-Identitaet und strukturierte Sensorbeobachtungen werden deklarativ auf kanonische Rollen abgebildet. Bestehende Profile und der generische Fallback bleiben unveraendert. + Abnahmekriterien: - Neue Profile benötigen keine Änderungen an React-Komponenten oder Formatparsern. @@ -401,6 +406,11 @@ Verifizierter Abschlussstand vom 2026-07-17: - Funktionsweise und Grenzen sind in docs/DYNAMIC_FILTERS.md dokumentiert. - 31 Testdateien mit 79 Tests, Local-only-Prüfung und Produktionsbuild laufen erfolgreich. +Verifizierte Erweiterung vom 2026-09-10: + +- Fuer Defender-for-IoT-Timeslots stellt Client Info getrennte CVE-, Device- und Site-Sichten bereit. In gemischten Timeslots bleibt die allgemeine Uebersicht erhalten und Defender erscheint als eigener Tab. Detail-Vorkommen, rohe Sensorbeobachtungen und nur aggregiert vorhandene Severity-Zahlen bleiben sichtbar getrennt; Zero und unavailable werden nicht gleichgesetzt. +- CVE- und Device-Auswahl sowie der verschachtelte Site-zu-Device-zu-CVE-Pfad machen die erhaltene Evidenz zugreifbar. Konflikte und Reconciliation-Diagnosen werden sichtbar gezaehlt, ohne Quellwerte still zu ueberschreiben. + Verifizierte Darstellungsnachschärfung vom 2026-08-02: - Das bisher als `Filter text` bezeichnete Volltextfeld heisst sichtbar `Search` und steht in der unteren Work-Kopfzeile unabhaengig von der Bookmark-Breite horizontal zentriert. Unterhalb von 1200 px faellt es auf die bestehende fliessende Anordnung zurueck; Filtersemantik und angedockte Results bleiben unveraendert. @@ -1013,6 +1023,12 @@ Verifizierter Abschlussstand von Phase 13 am 2026-07-17: Status: abgeschlossen +Verifizierte Erweiterung vom 2026-09-10: + +- Der Defender-for-IoT-Extractor 1.0.0 gruppiert Detailzeilen deterministisch zu Device-mal-CVE-Vorkommen, bewahrt jede Sensorbeobachtung mit Tabellen- und Zeilenlocator als strukturierte Evidenz und laesst Summary-Zeilen ohne CVE als nicht-findingbezogene Quelltabelle bestehen. +- Identitaet folgt MAC, danach gueltiger IP-Adresse, danach normalisiertem Namen im Site-Scope. Wiederverwendete Adressen, unvollstaendige Identitaeten, widerspruechliche CVE-Metadaten, befuellte `NULL`-Spalten, Dubletten und Summary-/Detailabweichungen erzeugen reviewbare Diagnosen. +- 33 fokussierte synthetische Import-, Profil-, Persistenzprojektions-, Datahub-Readiness- und Dashboardtests sowie der Gesamtlauf mit 764 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitspruefung, Designpruefung und vollstaendiger Client-/Server-Produktionsbuild sind erfolgreich. Keine API-, SQL- oder Persistenzmigration; die Phase bleibt abgeschlossen. + Ziele: - Die in den Phasen 3 bis 6 begonnene Trennung von technischem Dateiformat, Quellwerkzeug, Reportvariante, fachlicher Beobachtung und kanonischem Finding verbindlich vervollständigen. @@ -1617,6 +1633,8 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode | Risiko | Gegenmaßnahme | |---|---| +| Defender-for-IoT-Summarys koennen Severity-Zahlen ohne einzelne CVEs enthalten; wiederholte Sensorzeilen koennen dieselbe Device-CVE-Beziehung mehrfach melden, und pseudonymisierte oder wiederverwendete Adressen koennen Identitaeten mehrdeutig machen. | Aggregate ohne Detail niemals in Findings umwandeln, Device-mal-CVE vor der kanonischen Projektion deduplizieren, alle Sensorzeilen als Evidenz erhalten, Identitaet nach der dokumentierten MAC-/IP-/Name-Site-Reihenfolge bilden und Konflikte beziehungsweise Reconciliation-Abweichungen diagnostizieren statt still zusammenzufuehren. | +| Ein gemischtes All-Records-Blatt kann den generischen Titel `Name` auf alle Scanner anwenden und dadurch bei Defender die Device-IP statt der CVE anzeigen; zugleich kann eine rein profilgebundene Client-Info-Sonderansicht in gemischten Timeslots verschwinden. | Titel im gemischten Blatt je Datensatz aus dessen `Source: Scan`-Provenienz und deklarativem Werkzeugprofil bestimmen. Die allgemeine Client-Info-Uebersicht beibehalten und die Defender-Projektion bei vorhandenen Defender-Quellblaettern als eigenen Tab anbieten. | | Auswahl, Fokus und fachlicher Status koennen erneut farblich vermischt werden, wenn Komponenten direkt Akzent-, Fokus- oder Severitytokens fuer eine persistente Markierung verwenden. | Persistente Auswahl ausschliesslich an `--marking-color` binden und in beiden Themes auf denselben Gelbwert regressionspruefen. `--focus-ring` bleibt nur fuer Tastaturfokus; Online-, Fehler-, Workflow- und Severityfarben bleiben fachlich getrennt. | | Einzelansichten koennten trotz flacher gemeinsamer Tokens im Hover erneut dekorative Konturen setzen oder den Standard mit lokalen Radien und Schatten umgehen. | Appweite Projektregel und Regression an die flachen `--tile-*`-Tokens binden; normale und Hover-Zustaende getrennt von Fokus, Auswahl und Statusakzenten pruefen. Lokale Kachelgeometrie bleibt unzulaessig. | | Kontur- und schattenlose Client-Info-Kacheln koennen bei spaeteren Themeaenderungen ihren Flaechenkontrast zum Dashboardgrund verlieren; interaktive Kacheln koennten ohne eigene Zustandsmarkierung schwer erkennbar werden. | Karten- und Grundflaeche ueber die bestehenden Theme-Tokens getrennt halten, Hover weiterhin ueber die Hoverflaeche zeigen und Fokus sowie Auswahl mit den vorhandenen farbigen Konturen sichern. Die Dashboardregression prueft den flachen Normalzustand getrennt von diesen Funktionszustaenden. | @@ -1803,10 +1821,14 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode | 2026-08-04 | Phase-13B-Schritt-9/11 um den freigegebenen Policy-Evaluation-Regelkatalog nachgehaertet. Der implizite Equality-Fallback ist entfernt; nur eine exakte Policy-/Delivery-/Produkt-/Versions-/Rollen-/Scope-Regel darf Comparator und Risikoregel aktivieren. Ausdrueckliche Empfehlung, ausdrueckliche Nichtanwendbarkeit, keine Empfehlung und Katalogluecke werden getrennt gespeichert. Prerequisite-, Mutual-Exclusion- und Override-Regeln sind versioniert und konservativ an Snapshotvollstaendigkeit gebunden. Migration 016 persistiert Katalog-, Baselinezustands-, Regel-, Dependency- und Risikoprovenienz relational; die read-only Referenzdaten-API zeigt den aktiven Katalog. Der produktive Katalog 1.0.0 ist bis fachlicher Regelkuratierung bewusst leer und damit review-only. Synthetische Tests pruefen Minimum, Katalogluecke, Nichtanwendbarkeit, keine Empfehlung, Prerequisite, Mutual Exclusion und Override. `npm test`: 573 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Produktionsbuild erfolgreich. Die komplette PostgreSQL-Suite lief gegen eine frische PostgreSQL-16-Instanz mit 8 von 8 Tests erfolgreich; Migrationen 001 bis 016 und die relationalen Sicherheitsgrenzen wurden verifiziert, Container und synthetische Testdaten entfernt. Finding Factory, Unified-Result-Projektion, UI und reale Regelkuratierung bleiben offen; Phase 13B bleibt in Arbeit. | | Ein neuer Profilstand könnte ohne Manifestregistrierung importiert werden, kompatible Darstellungsänderungen könnten fälschlich semantische Vergleiche sperren oder semantische Änderungen könnten alte und neue Findings als gleich behandeln. Fehlende Scanneroriginale könnten außerdem einen behaupteten Reimport unmöglich machen. | Neue Verträge fail closed registrieren, Änderungsklasse und betroffene Dimensionen explizit testen, Timeslot- und Vergleichsgrenzen auf den kanonischen Kompatibilitätsschlüssel stützen und Reprocessing ausschließlich aus hashgeprüften Originalbytes erlauben. Bei `not-retained` ist ein erneuter Upload erforderlich. | +| Werkzeugerkennung und gespeichertes Verarbeitungsprofil koennen unterschiedliche IDs tragen; wenn die Werkzeug-ID den Profilvertrag ueberschreibt, erscheint ein registrierter Defender-Import faelschlich als unbekannt. | Kompatibilitaet ausschliesslich gegen `processedWith.profileId` und dessen Version pruefen. Die erkannte Werkzeug-ID bleibt Anzeige- und Gruppierungsmetadatum. Eine Regression sichert den abweichenden-ID-Fall, waehrend unbekannte Profil-/Versionspaare weiterhin fail closed bleiben. | ## 12. Änderungsprotokoll | 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-08-25 | Phase-7-Markierungsfarbe appweit auf Gelb vereinheitlicht: Der neue Theme-Token `--marking-color` traegt in Dark und Light denselben Wert und steuert Kachelauswahl, allgemeine Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl. Die violette Datahub-Auswahlkontur entfaellt. Tastaturfokus bleibt blau, Scanner-/Workflow-/Severitystatus behalten ihre fachlichen Farben. Projektregel und Kachelregression wurden aktualisiert. 24 fokussierte Auswahl-/Dashboard-/Graphtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. | | 2026-08-25 | Phase-7-Kachelstandard appweit auf die freigegebene flache Client-Info-Geometrie umgestellt: `--tile-border` ist in beiden Themes transparent, `--tile-radius` verwendet den zentralen Nullradius und `--tile-card-shadow` ist deaktiviert. Datahub-, Dashboard-, Sidebar-, Work- und Pathfinder-Hover veraendern die Flaeche ohne dekorative Kontur; Fokus, Auswahl und Statusmarkierungen bleiben sichtbar. `AGENTS.md` und die gemeinsamen Kachelregressionen wurden auf den neuen verbindlichen Standard aktualisiert. 24 fokussierte Kachel-/Dashboardtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. | | 2026-08-25 | Phase-7-Client-Info-Kacheldesign nach erneutem Referenzabgleich korrigiert: Die quadratischen Kennzahl- und Inhaltskacheln verwenden im Normalzustand keine helle Aussenkontur, keinen Kartenschatten und keine Rundung; auch die dekorative Linie unter Inhaltskartentiteln entfaellt. Flaechenkontrast und gemeinsamer Rasterabstand trennen die Kacheln wie im Sollbild, waehrend Hover, Tastaturfokus und aktive Filter funktional sichtbar bleiben. Neun fokussierte Dashboardtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. | 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 f929366..b8809fe 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: true }, { profileId: 'pingcastle', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'pingcastle-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, @@ -39,6 +40,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)); @@ -46,16 +51,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 72a07c1..7575cfe 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -6,6 +6,9 @@ import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/e import type { FindingRecord } from '../../domain/finding'; import type { ImportProfile } from '../../domain/import-profile'; import type { Review } from '../../domain/review'; +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, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets'; import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl'; @@ -584,13 +587,28 @@ function DashboardLargeCard({ title, meta, className = '', children }: { title: export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { const app = useApp(); const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app; + const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender'>('overview'); const facets = useMemo( () => (activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : []), [activeProfile, activeSheet, dataset, reviews] ); const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds); + const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]); + + useEffect(() => { + if (!hasDefender) setClientInfoView('overview'); + }, [hasDefender]); if (!dataset || !activeSheet) return ; + if (dataset.profileId === DEFENDER_FOR_IOT_PROFILE_ID) return ; + + const clientInfoTabs = hasDefender ? : undefined; + if (clientInfoView === 'defender' && hasDefender) { + return ; + } const total = activeSheet.analysis.records.length; const statusFacet: DynamicFacet | undefined = facets.find(facet => facet.source === 'profile-status'); @@ -640,6 +658,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { return (
+ {clientInfoTabs}

{dataset.fileName} {reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'} diff --git a/src/features/dataset-viewer/DatasetViewer.tsx b/src/features/dataset-viewer/DatasetViewer.tsx index a29ba5f..51a23bd 100644 --- a/src/features/dataset-viewer/DatasetViewer.tsx +++ b/src/features/dataset-viewer/DatasetViewer.tsx @@ -26,6 +26,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 @@ -128,9 +129,9 @@ export function DatasetViewer() { if (!activeSheet) return new Map(); return new Map(activeSheet.analysis.records.map(record => [ record.id, - (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title + recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset?.profileId) ])); - }, [activeSheet, titleColumn]); + }, [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 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