Neuer Stand Assessor #1
|
|
@ -10,6 +10,7 @@ src-tauri/gen/
|
|||
vite.config.js
|
||||
vite.config.d.ts
|
||||
*.png
|
||||
artifacts/objects/
|
||||
|
||||
.env.postgres
|
||||
.assessor-data/
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, JsonValue>;
|
||||
}
|
||||
|
||||
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<string, JsonValue>;
|
||||
}
|
||||
|
||||
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<string, JsonValue>;
|
||||
tableIndex: number;
|
||||
rowIndex: number;
|
||||
sourceFields: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
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<DefenderSeverity, DefenderSeverityMeasure>;
|
||||
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<DefenderSeverity, number>;
|
||||
aggregateOnly: Record<DefenderSeverity, number>;
|
||||
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<DefenderSourceRow, IdentitySeed & { id: string }> {
|
||||
const seeds = rows.map(row => ({ row, seed: identitySeed(row) }));
|
||||
const sitesByBase = new Map<string, Set<string>>();
|
||||
for (const { seed } of seeds) {
|
||||
if (seed.method === 'unresolved') continue;
|
||||
const key = `${seed.method}:${seed.base}`;
|
||||
const sites = sitesByBase.get(key) ?? new Set<string>();
|
||||
if (seed.site) sites.add(normalizedText(seed.site));
|
||||
sitesByBase.set(key, sites);
|
||||
}
|
||||
const warned = new Set<string>();
|
||||
const result = new Map<DefenderSourceRow, IdentitySeed & { id: string }>();
|
||||
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<DefenderSeverity, number> {
|
||||
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<string, DefenderSourceRow[]>();
|
||||
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<string, JsonValue> = {};
|
||||
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<string, DefenderObservation[]>();
|
||||
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<string, DefenderObservation[]>();
|
||||
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<string, DefenderOccurrence[]>();
|
||||
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<string, DefenderSourceRow[]>();
|
||||
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<string, DefenderOccurrence[]>();
|
||||
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<DefenderSeverity, number | undefined>;
|
||||
const measures = Object.fromEntries(severities.map(item => [item, detailState(summaryCounts[item], detailCounts[item], severitiesWithDetail.has(item))])) as Record<DefenderSeverity, DefenderSeverityMeasure>;
|
||||
const sourceRows = [...summaryGroup, ...detailRows.filter(row => identities.get(row)?.id === id)];
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 <EmptyWorkspace />;
|
||||
if (dataset.profileId === DEFENDER_FOR_IOT_PROFILE_ID) return <DefenderVulnerabilityPanel dataset={dataset} />;
|
||||
|
||||
const clientInfoTabs = hasDefender ? <nav className="client-info-tabs" aria-label="Client Info views">
|
||||
{([['overview', 'Overview'], ['defender', 'Microsoft Defender for IoT']] as const).map(([id, title]) =>
|
||||
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
|
||||
</nav> : undefined;
|
||||
if (clientInfoView === 'defender' && hasDefender) {
|
||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="dashboard-page">
|
||||
{clientInfoTabs}
|
||||
<p className="dashboard-context">
|
||||
<strong>{dataset.fileName}</strong>
|
||||
<span>{reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'}</span>
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
return new Map<string, string>(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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 <span className="defender-unavailable">Unavailable</span>;
|
||||
if (measure.state === 'aggregate-only') return <span><Count value={measure.summary ?? 0} /> <small>aggregate-only</small></span>;
|
||||
if (measure.state === 'mismatch') return <span><Count value={measure.detail} /> detailed <small>of {measure.summary?.toLocaleString('en-US') ?? 'unavailable'} summary</small></span>;
|
||||
if (measure.state === 'detail-only') return <span><Count value={measure.detail} /> <small>detail only</small></span>;
|
||||
return <Count value={measure.detail} />;
|
||||
}
|
||||
|
||||
function SeverityMark({ severity }: { severity: DefenderSeverity | 'unknown' }) {
|
||||
return <span className={`defender-severity severity-${severity}`}><i aria-hidden="true" />{label(severity)}</span>;
|
||||
}
|
||||
|
||||
function DeviceCves({ device, occurrences }: { device: DefenderDevice; occurrences: ReturnType<typeof defenderModelForDataset>['occurrences'] }) {
|
||||
const values = occurrences.filter(item => device.occurrenceIds.includes(item.id));
|
||||
return <div className="defender-drilldown">
|
||||
<h3>{device.displayName || 'Unnamed device'}</h3>
|
||||
<p>{device.ip || 'IP unavailable'} · {device.site || 'Site unavailable'} · Identity: {device.identityProvenance} ({device.identityConfidence})</p>
|
||||
<div className="defender-aggregate-summary" aria-label="Severity detail availability">
|
||||
{severities.map(item => <span key={item}><strong>{label(item)}</strong><Measure measure={device.severity[item]} /></span>)}
|
||||
</div>
|
||||
{values.length ? <table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th><th>Description</th><th>Reporting sensors</th></tr></thead><tbody>
|
||||
{values.map(item => <tr key={item.id}><td>{item.cve}</td><td><SeverityMark severity={item.severity} /></td><td>{item.description || 'Description unavailable'}</td><td>{item.sensors.join(', ') || 'Sensor unavailable'}</td></tr>)}
|
||||
</tbody></table> : <p className="defender-empty">No detailed CVE rows are available for this device. Summary counts remain visible above.</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: ImportedDataset; navigation?: ReactNode }) {
|
||||
const model = useMemo(() => defenderModelForDataset(dataset), [dataset]);
|
||||
const [view, setView] = useState<DefenderView>('cves');
|
||||
const [selectedCve, setSelectedCve] = useState('');
|
||||
const [selectedDevice, setSelectedDevice] = useState('');
|
||||
const [selectedSite, setSelectedSite] = useState('');
|
||||
const currentCve = model.cves.find(item => item.id === selectedCve);
|
||||
const currentDevice = model.devices.find(item => item.id === selectedDevice);
|
||||
const currentSite = model.sites.find(item => item.id === selectedSite);
|
||||
const affectedDevices = model.devices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length;
|
||||
const occurrenceSeverity = Object.fromEntries(severities.map(item => [item, model.occurrences.filter(occurrence => occurrence.severity === item).length])) as Record<DefenderSeverity, number>;
|
||||
|
||||
return <div className="defender-page">
|
||||
{navigation}
|
||||
<p className="defender-context"><strong>Microsoft Defender for IoT vulnerabilities</strong><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span><WorkTimeslotControl /></p>
|
||||
<section className="defender-metrics" aria-label="Vulnerability metrics">
|
||||
<article><span>Assets</span><strong><Count value={model.devices.length} /></strong></article>
|
||||
<article><span>Unique CVEs</span><strong><Count value={model.cves.length} /></strong></article>
|
||||
<article><span>Affected devices</span><strong><Count value={affectedDevices} /></strong></article>
|
||||
<article><span>Device × CVE occurrences</span><strong><Count value={model.occurrences.length} /></strong></article>
|
||||
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article>
|
||||
<article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
|
||||
</section>
|
||||
<nav className="defender-tabs" aria-label="Defender vulnerability views">
|
||||
{([['cves', 'CVEs'], ['devices', 'Devices'], ['sites', 'Sites']] as const).map(([id, title]) => <button type="button" key={id} aria-pressed={view === id} onClick={() => setView(id)}>{title}</button>)}
|
||||
</nav>
|
||||
|
||||
{view === 'cves' && <section className="defender-view" aria-labelledby="defender-cves-heading">
|
||||
<header><h2 id="defender-cves-heading">CVE view</h2><span>{model.cves.length.toLocaleString('en-US')} unique definitions</span></header>
|
||||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>CVE</th><th>Severity</th><th>Description</th><th>Affected devices</th><th>Sites</th><th>Sensors</th><th>Observations</th></tr></thead><tbody>
|
||||
{model.cves.map(cve => <tr key={cve.id}><td><button type="button" aria-pressed={selectedCve === cve.id} onClick={() => setSelectedCve(cve.id)}>{cve.id}</button>{(cve.conflictingDescription || cve.conflictingSeverity) && <small className="defender-conflict">Review conflict</small>}</td><td><SeverityMark severity={cve.severity} /></td><td>{cve.description || 'Description unavailable'}</td><td><Count value={cve.deviceIds.length} /></td><td>{cve.sites.join(', ') || 'Site unavailable'}</td><td>{cve.sensors.join(', ') || 'Sensor unavailable'}</td><td><Count value={cve.observationCount} /></td></tr>)}
|
||||
</tbody></table></div>
|
||||
{currentCve && <div className="defender-drilldown"><h3>{currentCve.id}</h3><p>{currentCve.description || 'Description unavailable'}</p>
|
||||
{(currentCve.conflictingDescription || currentCve.conflictingSeverity) && <p className="defender-warning">Conflicting source {currentCve.conflictingDescription && currentCve.conflictingSeverity ? 'descriptions and severity values' : currentCve.conflictingDescription ? 'descriptions' : 'severity values'} require review.</p>}
|
||||
<h4>Affected devices and source observations</h4>
|
||||
{model.occurrences.filter(item => item.cve === currentCve.id).map(occurrence => {
|
||||
const device = model.devices.find(item => item.id === occurrence.deviceId);
|
||||
return <details key={occurrence.id}><summary>{device?.displayName || 'Unnamed device'} · {device?.site || 'Site unavailable'} · {occurrence.observations.length.toLocaleString('en-US')} observation{occurrence.observations.length === 1 ? '' : 's'}</summary>
|
||||
<table className="defender-table"><thead><tr><th>Source row</th><th>Name</th><th>IP</th><th>Site</th><th>Sensor</th><th>Severity</th></tr></thead><tbody>{occurrence.observations.map(observation => <tr key={observation.id}><td>{observation.rowIndex + 1}</td><td>{observation.name || 'Unavailable'}</td><td>{observation.ip || 'Unavailable'}</td><td>{observation.site || 'Unavailable'}</td><td>{observation.sensor || 'Unavailable'}</td><td>{observation.severitySource || 'Unavailable'}</td></tr>)}</tbody></table>
|
||||
</details>;
|
||||
})}
|
||||
</div>}
|
||||
</section>}
|
||||
|
||||
{view === 'devices' && <section className="defender-view" aria-labelledby="defender-devices-heading">
|
||||
<header><h2 id="defender-devices-heading">Device view</h2><span>Detailed and aggregate-only counts stay separate</span></header>
|
||||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Device</th><th>IP</th><th>Site</th><th>Sensors</th>{severities.map(item => <th key={item}>{label(item)}</th>)}</tr></thead><tbody>
|
||||
{model.devices.map(device => <tr key={device.id}><td><button type="button" aria-pressed={selectedDevice === device.id} onClick={() => setSelectedDevice(device.id)}>{device.displayName || 'Unnamed device'}</button></td><td>{device.ip || 'Unavailable'}</td><td>{device.site || 'Unavailable'}</td><td>{device.sensors.join(', ') || 'Unavailable'}</td>{severities.map(item => <td key={item}><Measure measure={device.severity[item]} /></td>)}</tr>)}
|
||||
</tbody></table></div>
|
||||
{currentDevice && <DeviceCves device={currentDevice} occurrences={model.occurrences} />}
|
||||
</section>}
|
||||
|
||||
{view === 'sites' && <section className="defender-view" aria-labelledby="defender-sites-heading">
|
||||
<header><h2 id="defender-sites-heading">Site dashboard</h2><span>Occurrence totals exclude aggregate-only counts</span></header>
|
||||
<section className="defender-severity-strip" aria-label="Detailed occurrence severity distribution">{severities.map(item => <article key={item}><SeverityMark severity={item} /><strong><Count value={occurrenceSeverity[item]} /></strong></article>)}</section>
|
||||
<div className="defender-table-wrap"><table className="defender-table"><thead><tr><th>Site</th><th>Assets</th><th>Unique CVEs</th><th>Affected devices</th>{severities.map(item => <th key={item}>{label(item)} occurrences</th>)}<th>Aggregate-only</th><th>Raw observations</th><th>Detail completeness</th></tr></thead><tbody>
|
||||
{model.sites.map(site => <tr key={site.id}><td><button type="button" aria-pressed={selectedSite === site.id} onClick={() => setSelectedSite(site.id)}>{site.name}</button></td><td><Count value={site.assetCount} /></td><td><Count value={site.uniqueCveCount} /></td><td><Count value={site.affectedDeviceCount} /></td>{severities.map(item => <td key={item}><Count value={site.occurrences[item]} /></td>)}<td><Count value={severities.reduce((sum, item) => sum + site.aggregateOnly[item], 0)} /></td><td><Count value={site.rawObservationCount} /></td><td>{site.detailCompleteness}</td></tr>)}
|
||||
</tbody></table></div>
|
||||
{currentSite && <div className="defender-drilldown"><h3>{currentSite.name}</h3><p>Select each device below to follow the site → device → CVE evidence path.</p>{model.devices.filter(device => currentSite.deviceIds.includes(device.id)).map(device => <details key={device.id}><summary>{device.displayName || 'Unnamed device'} · {device.occurrenceIds.length.toLocaleString('en-US')} detailed CVE occurrence{device.occurrenceIds.length === 1 ? '' : 's'}</summary><DeviceCves device={device} occurrences={model.occurrences} /></details>)}</div>}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { JsonValue } from '../../domain/canonical-model';
|
||||
import {
|
||||
buildDefenderVulnerabilityModel,
|
||||
defenderTableRole,
|
||||
type DefenderSourceRow,
|
||||
type DefenderSourceTable,
|
||||
type DefenderVulnerabilityModel
|
||||
} from '../../domain/defender-for-iot';
|
||||
import type { DatasetSheet, ImportedDataset } from '../../domain/dataset';
|
||||
|
||||
function recordFields(sheet: DatasetSheet, recordIndex: number): Record<string, JsonValue> {
|
||||
const record = sheet.analysis.records[recordIndex];
|
||||
return Object.fromEntries(record.fields.map(field => [field.label, field.value]));
|
||||
}
|
||||
|
||||
function parsedObservations(fields: Record<string, JsonValue>): Array<Record<string, JsonValue>> | undefined {
|
||||
const source = Object.entries(fields).find(([label]) => /^Source Observations$/i.test(label.trim()))?.[1];
|
||||
if (typeof source !== 'string' || !source.trim()) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(source);
|
||||
return Array.isArray(parsed) && parsed.every(item => item && typeof item === 'object' && !Array.isArray(item))
|
||||
? parsed as Array<Record<string, JsonValue>>
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function observationRows(sheet: DatasetSheet, tableIndex: number): DefenderSourceRow[] | undefined {
|
||||
const expanded = sheet.analysis.records.flatMap((record, recordIndex) => {
|
||||
const observations = parsedObservations(recordFields(sheet, recordIndex));
|
||||
if (!observations) return [];
|
||||
return observations.map((observation, observationIndex): DefenderSourceRow => {
|
||||
const source = observation.source && typeof observation.source === 'object' && !Array.isArray(observation.source)
|
||||
? observation.source as Record<string, JsonValue>
|
||||
: {};
|
||||
const additional = observation.additionalSourceFields && typeof observation.additionalSourceFields === 'object' && !Array.isArray(observation.additionalSourceFields)
|
||||
? observation.additionalSourceFields as Record<string, JsonValue>
|
||||
: {};
|
||||
return {
|
||||
tableIndex: typeof source.tableIndex === 'number' ? source.tableIndex : tableIndex,
|
||||
rowIndex: typeof source.rowIndex === 'number' ? source.rowIndex : record.index + observationIndex,
|
||||
fields: {
|
||||
Name: observation.name ?? '', IP: observation.ip ?? '', Site: observation.site ?? '', Sensor: observation.sensor ?? '',
|
||||
CVE: observation.cve ?? '', Severity: observation.severity ?? '', NULL: additional.NULL ?? null,
|
||||
Beschreibung: observation.description ?? '', ...additional
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
return expanded.length ? expanded : undefined;
|
||||
}
|
||||
|
||||
function sourceTable(sheet: DatasetSheet, index: number): DefenderSourceTable | undefined {
|
||||
const tableIndex = sheet.sourceTableIndex ?? sheet.workbookIndex ?? index;
|
||||
const expanded = observationRows(sheet, tableIndex);
|
||||
if (expanded) return {
|
||||
tableIndex,
|
||||
name: sheet.sourceSheet ?? sheet.name,
|
||||
headers: ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung'],
|
||||
rows: expanded
|
||||
};
|
||||
if (!defenderTableRole(sheet.analysis.headers)) return undefined;
|
||||
return {
|
||||
tableIndex,
|
||||
name: sheet.sourceSheet ?? sheet.name,
|
||||
headers: sheet.analysis.headers,
|
||||
rows: sheet.analysis.records.map((record, recordIndex) => ({
|
||||
tableIndex,
|
||||
rowIndex: record.index,
|
||||
fields: recordFields(sheet, recordIndex)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function defenderModelForDataset(dataset: ImportedDataset): DefenderVulnerabilityModel {
|
||||
const tables = dataset.sheets
|
||||
.filter(sheet => !sheet.isProjectSheet)
|
||||
.map(sourceTable)
|
||||
.filter((table): table is DefenderSourceTable => Boolean(table));
|
||||
return buildDefenderVulnerabilityModel(tables);
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { FindingRecord, Severity } from '../../domain/finding';
|
||||
import { mitreBadgeLabel, normalizeSeverity } from '../../profiles/purple-knight/presentation';
|
||||
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
|
||||
import { recordTitleFor } from '../dataset-viewer/record-title';
|
||||
|
||||
export interface MitreTechnique {
|
||||
id: string;
|
||||
|
|
@ -160,7 +161,7 @@ export function buildFindingPathItems(records: FindingRecord[], data: MitreGraph
|
|||
const severity = normalizeSeverity(severityValue);
|
||||
const weight = numberField(record, /^weight$/i);
|
||||
const score = numberField(record, /^score$/i);
|
||||
const title = (titleColumn && record.fields.find(field => field.label === titleColumn)?.value) || record.title;
|
||||
const title = recordTitleFor(record, titleColumn, fallbackToolId);
|
||||
const categories = [...new Set([
|
||||
...record.fields
|
||||
.filter(field => /^category$/i.test(field.label.trim()) && field.value)
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
|
|||
const files = models.flatMap(model => model.importFiles);
|
||||
const tables = models.flatMap(model => model.sourceTables);
|
||||
const occurrences = models.flatMap(model => model.findingOccurrences);
|
||||
if (!runs.length || !occurrences.length) return null;
|
||||
if (!runs.length || (!occurrences.length && !tables.some(table => table.rows?.length))) return null;
|
||||
|
||||
const fileById = new Map(files.map(file => [file.id, file]));
|
||||
const runById = new Map(runs.map(run => [run.id, run]));
|
||||
|
|
@ -302,7 +302,9 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
|
|||
|
||||
const tool = projectTool(models);
|
||||
const aggregateName = project.name + ' · All Records';
|
||||
const aggregate = makeSheet(aggregateName, aggregateRows, rowsToCsv(aggregateRows), project.name, { isProjectSheet: true });
|
||||
const aggregate = occurrences.length
|
||||
? makeSheet(aggregateName, aggregateRows, rowsToCsv(aggregateRows), project.name, { isProjectSheet: true })
|
||||
: undefined;
|
||||
const lastModified = Math.max(...files.map(file => Date.parse(file.lastModifiedAt ?? file.importedAt)), 0);
|
||||
const sectionSheets = linkedSectionSheets(occurrences);
|
||||
const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean));
|
||||
|
|
@ -313,7 +315,7 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
|
|||
lastModified,
|
||||
format: 'Project · ' + tool.name,
|
||||
rawText: '',
|
||||
sheets: [aggregate, ...tableSheets, ...supportingSheets, ...sectionSheets],
|
||||
sheets: [...(aggregate ? [aggregate] : []), ...tableSheets, ...supportingSheets, ...sectionSheets],
|
||||
profileId: tool.id === 'unknown' || tool.id === 'multiple' ? 'generic' : tool.id,
|
||||
profileVersion: profileVersions.size === 1 ? [...profileVersions][0] : profileVersions.size > 1 ? 'gemischt' : undefined,
|
||||
scanTool: tool.name
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function summarizeScanProcessingReadiness(files: ImportFileRecord[]): Dat
|
|||
file.detectedTool.id || file.processedWith.profileId,
|
||||
file.processedWith
|
||||
))) ?? files[0];
|
||||
const current = representative ? currentProcessingManifest(representative.detectedTool.id || representative.processedWith.profileId) : undefined;
|
||||
const current = representative ? currentProcessingManifest(representative.processedWith.profileId) : undefined;
|
||||
if (statuses.includes('unknown')) {
|
||||
return { state: 'error', statuses, explanation: 'The stored ASSESSOR processing profile is unknown and cannot be assessed safely.' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { CanonicalAssessmentModel } from '../../domain/canonical-model';
|
||||
import { DEFENDER_FOR_IOT_PROFILE_ID, defenderTableRole } from '../../domain/defender-for-iot';
|
||||
|
||||
export type DatahubScanReadiness = 'recognized' | 'generic' | 'not-viewable';
|
||||
|
||||
const genericToolIds = new Set(['generic', 'unknown', 'multiple']);
|
||||
|
||||
/** A stored ScanRun becomes viewable in Work once it owns canonical records. */
|
||||
/** A stored ScanRun becomes viewable once it owns canonical findings or a supported non-finding projection. */
|
||||
export function datahubScanReadiness(
|
||||
models: CanonicalAssessmentModel[],
|
||||
scanRunId: string,
|
||||
|
|
@ -12,7 +13,12 @@ export function datahubScanReadiness(
|
|||
): DatahubScanReadiness {
|
||||
const hasRun = models.some(model => model.scanRuns.some(run => run.id === scanRunId));
|
||||
const hasViewableRecords = models.some(model => model.findingOccurrences.some(occurrence => occurrence.scanRunId === scanRunId));
|
||||
if (!hasRun || !hasViewableRecords) return 'not-viewable';
|
||||
const hasDefenderSummary = toolId === DEFENDER_FOR_IOT_PROFILE_ID && models.some(model => {
|
||||
const fileIds = new Set(model.importFiles.filter(file => file.scanRunId === scanRunId).map(file => file.id));
|
||||
return model.sourceTables.some(table => fileIds.has(table.importFileId) && Boolean(table.rows?.length)
|
||||
&& defenderTableRole(table.rows?.[0]?.fields.map(field => field.label) ?? []) === 'summary');
|
||||
});
|
||||
if (!hasRun || (!hasViewableRecords && !hasDefenderSummary)) return 'not-viewable';
|
||||
return genericToolIds.has(toolId) ? 'generic' : 'recognized';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import type { JsonValue } from '../domain/canonical-model';
|
||||
import {
|
||||
buildDefenderVulnerabilityModel,
|
||||
defenderTableRole,
|
||||
type DefenderDevice,
|
||||
type DefenderObservation,
|
||||
type DefenderSourceRow,
|
||||
type DefenderSourceTable
|
||||
} from '../domain/defender-for-iot';
|
||||
import type { DecodedSourceDocument, ImportTable, SourceLocator } from '../domain/import-intermediate';
|
||||
import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction';
|
||||
|
||||
function headerFor(table: ImportTable): string[] {
|
||||
return table.headerCandidates[0]?.headers ?? [];
|
||||
}
|
||||
|
||||
function sourceRows(table: ImportTable): DefenderSourceRow[] {
|
||||
const header = table.headerCandidates[0];
|
||||
if (!header) return [];
|
||||
return table.rows
|
||||
.filter(row => row.index !== header.rowIndex && row.cells.some(cell => cell.decodedValue.trim()))
|
||||
.map(row => ({
|
||||
tableIndex: table.index,
|
||||
rowIndex: row.index,
|
||||
fields: Object.fromEntries(row.cells.map((cell, index) => [header.headers[index] || `Column ${index + 1}`, cell.rawValue]))
|
||||
}));
|
||||
}
|
||||
|
||||
function sourceTables(document: DecodedSourceDocument): DefenderSourceTable[] {
|
||||
return document.tables.map(table => ({ tableIndex: table.index, name: table.name, headers: headerFor(table), rows: sourceRows(table) }));
|
||||
}
|
||||
|
||||
function observationValue(observation: DefenderObservation): Record<string, JsonValue> {
|
||||
return {
|
||||
name: observation.name,
|
||||
ip: observation.ip,
|
||||
site: observation.site,
|
||||
sensor: observation.sensor,
|
||||
cve: observation.cve,
|
||||
severity: observation.severitySource,
|
||||
description: observation.description,
|
||||
additionalSourceFields: observation.additionalSourceFields,
|
||||
source: { tableIndex: observation.tableIndex, rowIndex: observation.rowIndex }
|
||||
};
|
||||
}
|
||||
|
||||
function field(key: string, label: string, originalValue: JsonValue, decodedValue: string, source: SourceLocator): SourceFieldCandidate {
|
||||
return { key, label, originalValue, decodedValue, source };
|
||||
}
|
||||
|
||||
function occurrenceCandidate(
|
||||
document: DecodedSourceDocument,
|
||||
occurrence: ReturnType<typeof buildDefenderVulnerabilityModel>['occurrences'][number],
|
||||
device: DefenderDevice
|
||||
): FindingCandidate {
|
||||
const first = occurrence.observations[0];
|
||||
const severityObservation = occurrence.observations.find(observation => observation.severity === occurrence.severity) ?? first;
|
||||
const source = { fileName: document.file.name, tableIndex: first.tableIndex, rowIndex: first.rowIndex };
|
||||
const observations = occurrence.observations.map(observationValue);
|
||||
return {
|
||||
version: 1,
|
||||
id: `defender-occurrence-${first.tableIndex}-${first.rowIndex}`,
|
||||
source,
|
||||
fields: [
|
||||
field('cve', 'CVE', occurrence.cve, occurrence.cve, source),
|
||||
field('device-identity', 'Device Identity', occurrence.deviceId, occurrence.deviceId, source),
|
||||
field('identity-method', 'Identity Method', device.identityMethod, device.identityMethod, source),
|
||||
field('identity-confidence', 'Identity Confidence', device.identityConfidence, device.identityConfidence, source),
|
||||
field('identity-provenance', 'Identity Provenance', device.identityProvenance, device.identityProvenance, source),
|
||||
field('name', 'Name', first.name, first.name, source),
|
||||
field('ip', 'IP', first.ip, first.ip, source),
|
||||
field('site', 'Site', first.site, first.site, source),
|
||||
field('reporting-sensors', 'Reporting Sensors', occurrence.sensors, occurrence.sensors.join(', '), source),
|
||||
field('severity', 'Severity', severityObservation.severitySource, severityObservation.severitySource, source),
|
||||
field('description', 'Beschreibung', occurrence.description, occurrence.description, source),
|
||||
field('observation-count', 'Observation Count', observations.length, String(observations.length), source),
|
||||
field('source-observations', 'Source Observations', observations, JSON.stringify(observations), source)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export const defenderForIotSourceExtractor: SourceExtractor = {
|
||||
id: 'defender-for-iot-vulnerability-source-extractor',
|
||||
version: '1.0.0',
|
||||
detect(document) {
|
||||
const roles = document.tables.map(table => defenderTableRole(headerFor(table))).filter(Boolean);
|
||||
if (!roles.length) return { score: 0, evidence: [] };
|
||||
const both = roles.includes('summary') && roles.includes('detail');
|
||||
return {
|
||||
score: both ? 1 : 0.92,
|
||||
evidence: [both
|
||||
? 'A Defender for IoT asset summary and detailed vulnerability table were found.'
|
||||
: `A Defender for IoT ${roles[0]} table was found.`]
|
||||
};
|
||||
},
|
||||
extract(document) {
|
||||
const model = buildDefenderVulnerabilityModel(sourceTables(document));
|
||||
return {
|
||||
version: 1,
|
||||
candidates: model.occurrences.map(occurrence => occurrenceCandidate(
|
||||
document,
|
||||
occurrence,
|
||||
model.devices.find(device => device.id === occurrence.deviceId)!
|
||||
)),
|
||||
diagnostics: model.diagnostics.map(diagnostic => ({
|
||||
severity: diagnostic.severity,
|
||||
code: diagnostic.code,
|
||||
message: diagnostic.message,
|
||||
stage: 'extraction',
|
||||
source: diagnostic.tableIndex === undefined ? undefined : {
|
||||
fileName: document.file.name,
|
||||
tableIndex: diagnostic.tableIndex,
|
||||
rowIndex: diagnostic.rowIndex
|
||||
},
|
||||
details: diagnostic.details
|
||||
}))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import type { DecodedObjectView, DecodedSourceDocument } from '../domain/import-
|
|||
import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction';
|
||||
import { stringifyCell } from '../shared/text';
|
||||
import { cisCatSourceExtractor } from './cis-cat-source-extractor';
|
||||
import { defenderForIotSourceExtractor } from './defender-for-iot-source-extractor';
|
||||
import { pingCastleSourceExtractor } from './pingcastle-source-extractor';
|
||||
import { maesterHtmlSourceExtractor } from './maester-html-source-extractor';
|
||||
import { purpleKnightHtmlSourceExtractor } from './purple-knight-html-source-extractor';
|
||||
|
|
@ -91,6 +92,7 @@ export const genericTableSourceExtractor: SourceExtractor = {
|
|||
};
|
||||
|
||||
export const sourceExtractors: SourceExtractor[] = [
|
||||
defenderForIotSourceExtractor,
|
||||
pingCastleSourceExtractor,
|
||||
cisCatSourceExtractor,
|
||||
purpleKnightHtmlSourceExtractor,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import './styles/legacy-views.css';
|
|||
import './styles/obsidian-graph.css';
|
||||
import './styles/functional-icons.css';
|
||||
import './styles/tile-contract.css';
|
||||
import './styles/defender-for-iot.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ToolProfileDefinition } from '../../domain/tool-profile-definition';
|
||||
import { commonSeverityMappings, commonStatusMappings } from '../common-normalization';
|
||||
|
||||
export const defenderForIotDefinition: ToolProfileDefinition = {
|
||||
schemaVersion: 1,
|
||||
id: 'defender-for-iot-vulnerabilities',
|
||||
name: 'Microsoft Defender for IoT',
|
||||
version: '1.0.0',
|
||||
processing: {
|
||||
mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0',
|
||||
compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible',
|
||||
reimportRequired: false, affectedDimensions: []
|
||||
},
|
||||
sourceSignals: [
|
||||
{ scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' },
|
||||
{ scope: 'header', pattern: /^CVE$/i, contribution: 0.38, message: 'The detail table exposes CVE identifiers.' },
|
||||
{ scope: 'header', pattern: /^Sensor$/i, contribution: 0.14, message: 'The source records a reporting sensor.' },
|
||||
{ scope: 'header', pattern: /^Beschreibung$/i, contribution: 0.18, message: 'The detail table exposes the Defender CVE description field.' },
|
||||
{ scope: 'header', pattern: /^Critical$/i, contribution: 0.1, message: 'The asset summary exposes Critical counts.' }
|
||||
],
|
||||
tableRules: {
|
||||
preferredNames: [
|
||||
{ pattern: /asset|device|summary/i, contribution: 0.45, message: 'Likely asset summary table.' },
|
||||
{ pattern: /vulnerab|cve|detail/i, contribution: 0.55, message: 'Likely vulnerability detail table.' }
|
||||
],
|
||||
discouragedNames: []
|
||||
},
|
||||
fieldRules: [
|
||||
{ role: 'rule-id', headers: [{ pattern: /^CVE$/i, contribution: 1, message: 'CVE is the source-native finding identifier.' }] },
|
||||
{ role: 'description', headers: [{ pattern: /^(?:Beschreibung|Description)$/i, contribution: 1, message: 'The field contains the CVE description.' }] },
|
||||
{ role: 'severity', headers: [{ pattern: /^Severity$/i, contribution: 1, message: 'The field contains the vulnerability severity.' }] },
|
||||
{ role: 'asset', headers: [
|
||||
{ pattern: /^Device Identity$/i, contribution: 1, message: 'The extractor supplied the normalized device identity.' },
|
||||
{ pattern: /^Name$/i, contribution: 0.9, message: 'Name identifies the source asset.' }
|
||||
] },
|
||||
{ role: 'category', headers: [{ pattern: /^Site$/i, contribution: 0.92, message: 'Site groups affected assets.' }] },
|
||||
{ role: 'score', headers: [{ pattern: /^TotalCVEs$/i, contribution: 0.92, message: 'TotalCVEs is the source asset total.' }] },
|
||||
{ role: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] }
|
||||
],
|
||||
normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
|
||||
presentation: {
|
||||
titleHeaders: [/^CVE$/i],
|
||||
severityHeaders: [/^Severity$/i],
|
||||
sortHeaders: [/^Severity$/i],
|
||||
sortDirection: 'desc',
|
||||
defaultStatusFilter: 'deviation',
|
||||
statusFilters: [
|
||||
{ value: 'all', label: 'All Vulnerabilities', patterns: [] },
|
||||
{ value: 'deviation', label: 'Vulnerabilities', patterns: [/^critical$/i, /^high$/i, /^medium$/i, /^low$/i] }
|
||||
],
|
||||
resultSectionHeaders: [/^Source Observations$/i]
|
||||
},
|
||||
comparisonKeys: [['rule-id', 'asset']]
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { createImportProfile } from '../profile-factory';
|
||||
import { defenderForIotDefinition } from './defender-for-iot-definition';
|
||||
|
||||
export const defenderForIotProfile = createImportProfile(defenderForIotDefinition);
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import type { ImportProfile } from '../domain/import-profile';
|
||||
import { cisCatProfile } from './cis-cat/cis-cat-profile';
|
||||
import { defenderForIotProfile } from './defender-for-iot/defender-for-iot-profile';
|
||||
import { genericProfile } from './generic/generic-profile';
|
||||
import { maesterProfile } from './maester/maester-profile';
|
||||
import { pingCastleProfile } from './pingcastle/pingcastle-profile';
|
||||
import { purpleKnightProfile } from './purple-knight/purple-knight-profile';
|
||||
|
||||
export const importProfiles: ImportProfile[] = [
|
||||
defenderForIotProfile,
|
||||
purpleKnightProfile,
|
||||
pingCastleProfile,
|
||||
cisCatProfile,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import type { ToolProfileDefinition } from '../domain/tool-profile-definition';
|
||||
import { cisCatDefinition } from './cis-cat/cis-cat-definition';
|
||||
import { defenderForIotDefinition } from './defender-for-iot/defender-for-iot-definition';
|
||||
import { genericDefinition } from './generic/generic-definition';
|
||||
import { maesterDefinition } from './maester/maester-definition';
|
||||
import { pingCastleDefinition } from './pingcastle/pingcastle-definition';
|
||||
import { purpleKnightDefinition } from './purple-knight/purple-knight-definition';
|
||||
|
||||
export const toolProfileDefinitions: ToolProfileDefinition[] = [
|
||||
defenderForIotDefinition,
|
||||
purpleKnightDefinition,
|
||||
pingCastleDefinition,
|
||||
cisCatDefinition,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
.defender-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--segment-gap); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
|
||||
.defender-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-context strong { color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||
.defender-context .work-timeslot-select { margin-left: auto; }
|
||||
.defender-metrics { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||
.defender-metrics article { display: grid; align-content: space-between; gap: var(--space-3); min-height: 128px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||
.defender-metrics span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
|
||||
.defender-metrics strong { color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-regular); font-variant-numeric: tabular-nums; }
|
||||
.defender-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.defender-tabs button, .client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||
.defender-tabs button:hover, .client-info-tabs button:hover { background: var(--tile-surface-hover); }
|
||||
.defender-tabs button:focus-visible, .client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||
.defender-tabs button[aria-pressed="true"], .client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
|
||||
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||
.defender-view > header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.defender-view > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); }
|
||||
.defender-view > header span { color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-table-wrap { min-width: 0; overflow-x: auto; background: var(--tile-surface); }
|
||||
.defender-table { width: 100%; border-collapse: collapse; table-layout: auto; color: var(--text); background: var(--tile-surface); font-size: var(--type-table-body-size); }
|
||||
.defender-table th { padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--line-strong); color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); text-align: left; text-transform: uppercase; vertical-align: bottom; }
|
||||
.defender-table td { max-width: 440px; padding: var(--space-3); border-bottom: var(--border-hair) solid var(--line); line-height: 1.45; overflow-wrap: normal; word-break: normal; vertical-align: top; }
|
||||
.defender-table td > button { padding: 0; border: 0; color: var(--heading); background: transparent; font: inherit; font-weight: var(--fw-semibold); text-align: left; cursor: pointer; }
|
||||
.defender-table td > button:hover { background: var(--tile-surface-hover); }
|
||||
.defender-table td > button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 2px; }
|
||||
.defender-table td > button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: 2px; }
|
||||
.defender-table small { display: block; margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
||||
.defender-severity { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||
.defender-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
|
||||
.defender-conflict, .defender-warning { color: var(--warning); }
|
||||
.defender-unavailable, .defender-empty { color: var(--muted); }
|
||||
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||
.defender-drilldown h3, .defender-drilldown h4 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
|
||||
.defender-drilldown h3 { font-size: var(--type-detail-title-size); }
|
||||
.defender-drilldown h4 { font-size: var(--type-h4-size); }
|
||||
.defender-drilldown p { margin: 0; font-size: var(--type-body-size); line-height: 1.55; }
|
||||
.defender-drilldown details { border-top: var(--border-hair) solid var(--line); padding-top: var(--space-3); }
|
||||
.defender-drilldown summary { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); cursor: pointer; }
|
||||
.defender-drilldown details > .defender-table, .defender-drilldown details > .defender-drilldown { margin-top: var(--space-3); }
|
||||
.defender-aggregate-summary, .defender-severity-strip { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); }
|
||||
.defender-aggregate-summary > span, .defender-severity-strip article { display: grid; gap: var(--space-1); min-width: var(--tile-compact-width); padding: var(--space-3); background: var(--surface-inset); }
|
||||
.defender-aggregate-summary strong { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); }
|
||||
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); }
|
||||
.defender-severity-strip article { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
|
||||
.defender-severity-strip strong { color: var(--heading); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 760px) { .defender-view > header { display: grid; } .defender-context .work-timeslot-select { margin-left: 0; } }
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
--tile-border: transparent;
|
||||
--tile-selected-border: var(--marking-color);
|
||||
--tile-radius: var(--radius-sm);
|
||||
--tile-inset: var(--space-3);
|
||||
--tile-compact-width: 186px;
|
||||
--tile-content-width: 320px;
|
||||
--tile-card-shadow: none;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const layout = readFileSync(new URL('../../src/styles/layout.css', import.meta.u
|
|||
|
||||
describe('ASSESSOR version in the brand header', () => {
|
||||
it('shows only the centrally maintained processing version below the brand name', () => {
|
||||
expect(CURRENT_ASSESSOR_PROCESSING_VERSION).toBe('1.3.0');
|
||||
expect(CURRENT_ASSESSOR_PROCESSING_VERSION).toBe('1.4.0');
|
||||
expect(app).toContain("import { CURRENT_ASSESSOR_PROCESSING_VERSION } from '../domain/processing-compatibility';");
|
||||
expect(app).toContain('<span className="brand-copy"><h1>ASSESSOR</h1><span className="brand-version">{CURRENT_ASSESSOR_PROCESSING_VERSION}</span></span>');
|
||||
expect(app).not.toContain('>Version {CURRENT_ASSESSOR_PROCESSING_VERSION}');
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ describe('processing compatibility contracts', () => {
|
|||
expect(processingScopeIssues([old, contextless, current])).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the recorded profile when a caller supplies a different detected tool id', () => {
|
||||
const defender = file('defender', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0');
|
||||
expect(processingReadiness('microsoft-defender-for-iot', defender.processedWith)).toBe('current');
|
||||
expect(resolvedCompatibilityKey('microsoft-defender-for-iot', defender.processedWith))
|
||||
.toBe('defender-for-iot-vulnerabilities-v1');
|
||||
});
|
||||
|
||||
it('blocks semantically incompatible versions in one scope', () => {
|
||||
const legacy = file('legacy', 'pingcastle', '1.0.0', '1.0.0');
|
||||
const current = file('current', 'pingcastle', '1.4.0', '1.2.0');
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ function file(scanRunId: string, profileId: string, profileVersion: string, comp
|
|||
};
|
||||
}
|
||||
|
||||
function withDetectedTool(fileRecord: ImportFileRecord, id: string): ImportFileRecord {
|
||||
return { ...fileRecord, detectedTool: { id, name: 'Microsoft Defender for IoT' } };
|
||||
}
|
||||
|
||||
describe('Datahub client processing readiness', () => {
|
||||
it('marks current processing contracts green', () => {
|
||||
expect(summarizeClientProcessingReadiness(
|
||||
|
|
@ -34,6 +38,16 @@ describe('Datahub client processing readiness', () => {
|
|||
)).toEqual({ state: 'ready', label: 'Processing ready across all client timeslots.' });
|
||||
});
|
||||
|
||||
it('assesses the stored profile contract independently from the detected tool identity', () => {
|
||||
const defender = withDetectedTool(
|
||||
file('run-defender', 'defender-for-iot-vulnerabilities', '1.0.0', 'defender-for-iot-vulnerabilities-v1'),
|
||||
'microsoft-defender-for-iot'
|
||||
);
|
||||
expect(summarizeScanProcessingReadiness([defender])).toEqual({
|
||||
state: 'ready', statuses: ['current'], explanation: 'The scan uses a current or compatible ASSESSOR processing profile.'
|
||||
});
|
||||
});
|
||||
|
||||
it('marks reimport recommendations as a yellow update notice', () => {
|
||||
expect(summarizeClientProcessingReadiness(
|
||||
[file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')],
|
||||
|
|
|
|||
|
|
@ -26,4 +26,16 @@ describe('Datahub scan readiness', () => {
|
|||
expect(isDatahubScanViewable('checking')).toBe(false);
|
||||
expect(isDatahubScanViewable('unavailable')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a Defender asset summary viewable without fabricating findings', () => {
|
||||
const value = model(['defender-summary'], []);
|
||||
value.importFiles = [{ id: 'file-1', scanRunId: 'defender-summary' }] as CanonicalAssessmentModel['importFiles'];
|
||||
value.sourceTables = [{
|
||||
id: 'table-1', importFileId: 'file-1', name: 'Assets', index: 0, rowCount: 1, columnCount: 9, attributes: {},
|
||||
rows: [{ index: 1, fields: ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'].map((label, columnIndex) => ({ key: `column-${columnIndex}`, label, value: columnIndex > 3 ? 0 : 'synthetic' })) }]
|
||||
}];
|
||||
|
||||
expect(datahubScanReadiness([value], 'defender-summary', 'defender-for-iot-vulnerabilities')).toBe('recognized');
|
||||
expect(value.findingOccurrences).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ImportedDataset } from '../../src/domain/dataset';
|
||||
import { rowsToCsv } from '../../src/exports/csv-export';
|
||||
import { defenderModelForDataset } from '../../src/features/defender-for-iot/defender-dataset-model';
|
||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||
|
||||
function dataset(): ImportedDataset {
|
||||
const summary = [
|
||||
['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'],
|
||||
['device-a', '', 'Site A', 'sensor-a', '3', '1', '2', '0', '0']
|
||||
];
|
||||
const observations = [
|
||||
{ name: 'device-a', ip: '', site: 'Site A', sensor: 'sensor-a', cve: 'CVE-2026-1001', severity: 'Critical', description: 'Synthetic description', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 1 } },
|
||||
{ name: 'device-a', ip: '', site: 'Site A', sensor: 'sensor-b', cve: 'CVE-2026-1001', severity: 'Critical', description: 'Synthetic description', additionalSourceFields: {}, source: { tableIndex: 1, rowIndex: 2 } }
|
||||
];
|
||||
const detail = [
|
||||
['CVE', 'Device Identity', 'Name', 'IP', 'Site', 'Reporting Sensors', 'Severity', 'Beschreibung', 'Observation Count', 'Source Observations'],
|
||||
['CVE-2026-1001', 'name:device-a|site:site a', 'device-a', '', 'Site A', 'sensor-a, sensor-b', 'Critical', 'Synthetic description', '2', JSON.stringify(observations)]
|
||||
];
|
||||
return {
|
||||
fileName: 'Synthetic project', fileSize: 1, lastModified: 0, format: 'Project · Microsoft Defender for IoT', rawText: '',
|
||||
profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', scanTool: 'Microsoft Defender for IoT',
|
||||
sheets: [
|
||||
makeSheet('Vulnerabilities', detail, rowsToCsv(detail), 'synthetic.xlsx', { sourceTableIndex: 1, sourceSheet: 'Vulnerabilities', toolId: 'defender-for-iot-vulnerabilities' }),
|
||||
makeSheet('Assets', summary, rowsToCsv(summary), 'synthetic.xlsx', { sourceTableIndex: 0, sourceSheet: 'Assets', toolId: 'defender-for-iot-vulnerabilities' })
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
describe('Defender vulnerability dashboard projection', () => {
|
||||
it('reconstructs raw observations without double-counting the actionable occurrence', () => {
|
||||
const model = defenderModelForDataset(dataset());
|
||||
|
||||
expect(model.occurrences).toHaveLength(1);
|
||||
expect(model.observations).toHaveLength(2);
|
||||
expect(model.devices[0].severity.critical).toMatchObject({ detail: 1, summary: 1, state: 'complete' });
|
||||
expect(model.devices[0].severity.high).toMatchObject({ detail: 0, summary: 2, aggregateOnly: 2, state: 'aggregate-only' });
|
||||
});
|
||||
|
||||
it('keeps the required drill-downs and avoids clipping or ellipsis rules', () => {
|
||||
const component = readFileSync(new URL('../../src/features/defender-for-iot/DefenderVulnerabilityPanel.tsx', import.meta.url), 'utf8');
|
||||
const dashboard = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
||||
const styles = readFileSync(new URL('../../src/styles/defender-for-iot.css', import.meta.url), 'utf8');
|
||||
|
||||
expect(component).toContain('CVE view');
|
||||
expect(component).toContain('Device view');
|
||||
expect(component).toContain('Site dashboard');
|
||||
expect(component).toContain('Source row');
|
||||
expect(component).toContain('aggregate-only');
|
||||
expect(dashboard).toContain("['overview', 'Overview']");
|
||||
expect(dashboard).toContain("['defender', 'Microsoft Defender for IoT']");
|
||||
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />');
|
||||
expect(styles).toContain('padding: var(--panel-inset)');
|
||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--marking-color)');
|
||||
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
|
||||
expect(styles).not.toContain('text-overflow: ellipsis');
|
||||
expect(styles).not.toContain('line-clamp');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { FindingRecord } from '../../src/domain/finding';
|
||||
import { recordTitleFor } from '../../src/features/dataset-viewer/record-title';
|
||||
|
||||
function record(fields: Array<[string, string]>, title = 'fallback'): FindingRecord {
|
||||
return {
|
||||
id: 'synthetic-record', index: 0, title,
|
||||
fields: fields.map(([label, value], index) => ({ label, value, index })),
|
||||
filled: [], meta: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('mixed Work record titles', () => {
|
||||
it('uses the Defender CVE instead of the device name selected by the generic aggregate profile', () => {
|
||||
const defender = record([
|
||||
['Source: Scan', 'Microsoft Defender for IoT · 2026-09-10T08:00:00.000Z'],
|
||||
['Name', '10.0.0.42'],
|
||||
['CVE', 'CVE-2026-4242']
|
||||
]);
|
||||
|
||||
expect(recordTitleFor(defender, 'Name', 'generic')).toBe('CVE-2026-4242');
|
||||
});
|
||||
|
||||
it('keeps the selected title column for records without a tool-specific title field', () => {
|
||||
const generic = record([['Name', 'Synthetic finding'], ['Value', '42']]);
|
||||
expect(recordTitleFor(generic, 'Name', 'generic')).toBe('Synthetic finding');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { buildDefenderVulnerabilityModel, type DefenderSourceRow, type DefenderSourceTable } from '../../src/domain/defender-for-iot';
|
||||
import { importFile } from '../../src/importers/importer';
|
||||
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
|
||||
import { importProfiles } from '../../src/profiles/profile-catalog';
|
||||
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
||||
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
|
||||
import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness';
|
||||
|
||||
const summaryHeaders = ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'];
|
||||
const detailHeaders = ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung'];
|
||||
|
||||
function table(tableIndex: number, headers: string[], rows: Array<Record<string, unknown>>): DefenderSourceTable {
|
||||
return {
|
||||
tableIndex,
|
||||
name: headers.includes('CVE') ? 'Vulnerability detail' : 'Asset summary',
|
||||
headers,
|
||||
rows: rows.map((fields, index): DefenderSourceRow => ({ tableIndex, rowIndex: index + 1, fields: fields as DefenderSourceRow['fields'] }))
|
||||
};
|
||||
}
|
||||
|
||||
function summary(name: string, ip: string, site: string, counts: [number, number, number, number], sensor = 'sensor-a') {
|
||||
return { Name: name, IP: ip, Site: site, Sensor: sensor, TotalCVEs: counts.reduce((sum, value) => sum + value, 0), Critical: counts[0], High: counts[1], Medium: counts[2], Low: counts[3] };
|
||||
}
|
||||
|
||||
function detail(name: string, ip: string, site: string, sensor: string, cve: string, severity: string, description = 'Synthetic description', nullValue: unknown = null) {
|
||||
return { Name: name, IP: ip, Site: site, Sensor: sensor, CVE: cve, Severity: severity, NULL: nullValue, Beschreibung: description };
|
||||
}
|
||||
|
||||
describe('Microsoft Defender for IoT vulnerability model', () => {
|
||||
it('models CVEs, devices, occurrences, observations, and aggregate-only counts independently', () => {
|
||||
const model = buildDefenderVulnerabilityModel([
|
||||
table(0, summaryHeaders, [
|
||||
summary('00:11:22:33:44:55', '10.0.0.1', 'North', [2, 3, 0, 1]),
|
||||
summary('device-b', '10.0.0.2', 'North', [1, 0, 0, 0])
|
||||
]),
|
||||
table(1, detailHeaders, [
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical'),
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-b', 'CVE-2026-0001', 'Critical'),
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0002', 'Critical'),
|
||||
detail('device-b', '10.0.0.2', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical')
|
||||
])
|
||||
]);
|
||||
|
||||
expect(model.cves).toHaveLength(2);
|
||||
expect(model.devices).toHaveLength(2);
|
||||
expect(model.occurrences).toHaveLength(3);
|
||||
expect(model.observations).toHaveLength(4);
|
||||
expect(model.cves.find(item => item.id === 'CVE-2026-0001')).toMatchObject({ deviceIds: expect.arrayContaining([expect.any(String), expect.any(String)]), observationCount: 3 });
|
||||
expect(model.occurrences.find(item => item.cve === 'CVE-2026-0001' && item.observations.length === 2)?.sensors).toEqual(['sensor-a', 'sensor-b']);
|
||||
expect(model.devices.find(item => item.displayName === '00:11:22:33:44:55')?.severity.high).toMatchObject({ detail: 0, summary: 3, aggregateOnly: 3, state: 'aggregate-only' });
|
||||
expect(model.aggregateOnlyCount).toBe(4);
|
||||
expect(model.sites[0]).toMatchObject({ assetCount: 2, uniqueCveCount: 2, affectedDeviceCount: 2, rawObservationCount: 4 });
|
||||
});
|
||||
|
||||
it('uses MAC, IP, and site-scoped name identities without merging a reused IP across sites', () => {
|
||||
const model = buildDefenderVulnerabilityModel([table(0, detailHeaders, [
|
||||
detail('AA-BB-CC-DD-EE-FF', '10.0.0.1', 'A', 's1', 'CVE-2026-0101', 'High'),
|
||||
detail('device-ip', '10.0.0.2', 'A', 's1', 'CVE-2026-0102', 'High'),
|
||||
detail('device-name', '', 'A', 's1', 'CVE-2026-0103', 'Medium'),
|
||||
detail('device-reused-a', '10.0.0.9', 'A', 's1', 'CVE-2026-0104', 'Low'),
|
||||
detail('device-reused-b', '10.0.0.9', 'B', 's2', 'CVE-2026-0104', 'Low'),
|
||||
detail('', '', '', '', 'CVE-2026-0105', 'Critical')
|
||||
])]);
|
||||
|
||||
expect(model.devices.map(item => item.identityMethod)).toEqual(expect.arrayContaining(['mac', 'ip', 'name', 'unresolved']));
|
||||
expect(model.devices.filter(item => item.ip === '10.0.0.9')).toHaveLength(2);
|
||||
expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([
|
||||
'defender-device-identity-site-scoped', 'defender-device-identity-unresolved'
|
||||
]));
|
||||
});
|
||||
|
||||
it('keeps duplicate rows as evidence and flags conflicts, unexpected NULL data, and reconciliation failures', () => {
|
||||
const duplicate = detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0201', 'Critical', 'Description A');
|
||||
const model = buildDefenderVulnerabilityModel([
|
||||
table(0, summaryHeaders, [
|
||||
{ ...summary('device-a', '', 'A', [3, 0, 0, 0]), TotalCVEs: 99 },
|
||||
summary('device-a', '', 'A', [2, 0, 0, 0])
|
||||
]),
|
||||
table(1, detailHeaders, [
|
||||
duplicate,
|
||||
{ ...duplicate },
|
||||
detail('device-a', '', 'A', 'sensor-b', 'CVE-2026-0201', 'High', 'Description B', 'preserve-me'),
|
||||
detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0202', 'Critical')
|
||||
])
|
||||
]);
|
||||
|
||||
expect(model.occurrences).toHaveLength(2);
|
||||
expect(model.observations).toHaveLength(4);
|
||||
expect(model.observations.some(item => item.additionalSourceFields.NULL === 'preserve-me')).toBe(true);
|
||||
expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([
|
||||
'defender-summary-total-mismatch',
|
||||
'defender-summary-conflict',
|
||||
'defender-duplicate-source-observation',
|
||||
'defender-null-column-populated',
|
||||
'defender-cve-severity-conflict',
|
||||
'defender-cve-description-conflict',
|
||||
'defender-summary-detail-mismatch'
|
||||
]));
|
||||
});
|
||||
|
||||
it('distinguishes zero, unavailable detail, and single-sheet variants', () => {
|
||||
const summaryOnly = buildDefenderVulnerabilityModel([table(0, summaryHeaders, [summary('device-a', '', 'A', [0, 2, 0, 0])])]);
|
||||
const detailOnly = buildDefenderVulnerabilityModel([table(0, detailHeaders, [detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0301', 'Medium')])]);
|
||||
|
||||
expect(summaryOnly.available).toBe(true);
|
||||
expect(summaryOnly.occurrences).toHaveLength(0);
|
||||
expect(summaryOnly.devices[0].severity.critical.state).toBe('zero');
|
||||
expect(summaryOnly.devices[0].severity.high.state).toBe('aggregate-only');
|
||||
expect(detailOnly.devices[0].severity.medium.state).toBe('detail-only');
|
||||
expect(detailOnly.devices[0].severity.low.state).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('selects the versioned profile and emits one candidate per device and CVE', async () => {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
|
||||
summary('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', [1, 2, 0, 0])
|
||||
], { header: summaryHeaders }), 'Assets');
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-a', 'CVE-2026-0401', 'Critical'),
|
||||
detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-b', 'CVE-2026-0401', 'Critical')
|
||||
], { header: detailHeaders }), 'Vulnerabilities');
|
||||
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
||||
const imported = await importFile(new File([bytes], 'defender-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles });
|
||||
|
||||
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
|
||||
expect(imported.profileVersion).toBe('1.0.0');
|
||||
expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' });
|
||||
expect(imported.extraction?.candidates).toHaveLength(1);
|
||||
expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(2);
|
||||
expect(imported.mapping?.candidates).toHaveLength(1);
|
||||
expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([1, 2]);
|
||||
|
||||
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
|
||||
sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
||||
}), importProfiles);
|
||||
const canonical = createCanonicalImportBatch('defender-synthetic', [entry], '2026-09-10T06:00:00.000Z').model;
|
||||
expect(canonical.importFiles[0]).toMatchObject({
|
||||
detectedTool: { id: 'defender-for-iot-vulnerabilities' },
|
||||
processedWith: {
|
||||
profileId: 'defender-for-iot-vulnerabilities',
|
||||
profileVersion: '1.0.0',
|
||||
compatibilityKey: 'defender-for-iot-vulnerabilities-v1'
|
||||
}
|
||||
});
|
||||
expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] });
|
||||
expect(canonical.findingDefinitions).toHaveLength(1);
|
||||
expect(canonical.findingOccurrences).toHaveLength(1);
|
||||
expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toContain('mac:001122334455');
|
||||
expect(canonical.evidence).toHaveLength(1);
|
||||
expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) });
|
||||
expect(canonical.evidence[0].value).toHaveLength(2);
|
||||
expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a summary-only import viewable without creating finding definitions or occurrences', async () => {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([
|
||||
summary('synthetic-device', '', 'Synthetic site', [0, 2, 0, 0])
|
||||
], { header: summaryHeaders }), 'Assets');
|
||||
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
||||
const imported = await importFile(new File([bytes], 'defender-summary-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles });
|
||||
const entry = confirmEntryMappings(previewEntryFromDataset(imported, {
|
||||
sha256: 'e'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
||||
}), importProfiles);
|
||||
const canonical = createCanonicalImportBatch('defender-summary-synthetic', [entry], '2026-09-10T06:00:00.000Z').model;
|
||||
const projected = canonicalModelToDataset(canonical);
|
||||
|
||||
expect(imported.profileId).toBe('defender-for-iot-vulnerabilities');
|
||||
expect(canonical.findingDefinitions).toHaveLength(0);
|
||||
expect(canonical.findingOccurrences).toHaveLength(0);
|
||||
expect(projected?.profileId).toBe('defender-for-iot-vulnerabilities');
|
||||
expect(projected?.sheets.some(sheet => sheet.analysis.headers.includes('TotalCVEs'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue