feat(cve-enrichment): implement CVE enrichment service and database schema

This commit is contained in:
Binh 2026-09-11 08:58:36 +02:00
parent 5fbbda22b3
commit e04829597c
31 changed files with 955 additions and 16 deletions

View File

@ -10,8 +10,9 @@ ASSESSOR besitzt zwei kontrollierte lokale Betriebsarten:
2. Im Vite-Entwicklungs- oder optionalen Tauri-Modus bleibt der vorhandene
Browserparser als Fallback aktiv.
Es gibt keinen Upload an externe Dienste, keine Telemetrie und keine externen
Anwendungs-APIs. Das lokale Backend bindet standardmäßig nur an `127.0.0.1`.
Es gibt keinen Upload von Importdateien an externe Dienste und keine Telemetrie.
Die einzige freigegebene externe Anwendungs-API ist die unten beschriebene,
manuell ausgelöste NVD-CVE-Anreicherung. Das lokale Backend bindet standardmäßig nur an `127.0.0.1`.
Eine Freigabe im Firmennetz ist erst mit HTTPS, Authentifizierung und
Berechtigungen in Phase 14 zulässig.
@ -69,6 +70,25 @@ maskiert), `tests/importers/excel-importer.test.ts` (Zellverweis wird
vor dem Parsing geprüft. Große Base64-Zeichenfolgen werden iterativ validiert,
damit die API-Prüfung selbst keinen JavaScript-Stacküberlauf erzeugt.
### Externe NVD-CVE-Anreicherung
- Ein Import löst niemals selbst einen externen Netzwerkzugriff aus. Die
Anreicherung wird je bereits gespeichertem Defender-for-IoT-CVE bewusst über
`POST /api/v1/cves/:cveId/enrich` gestartet.
- Nur das Backend verbindet sich mit dem fest codierten Ursprung
`https://services.nvd.nist.gov/rest/json/cves/2.0`; die Browser-App bleibt auf
der Same-Origin-API. Benutzerkontrollierte Ziel-URLs werden nicht abgerufen.
- Zulässig sind ausschließlich normalisierte CVE-IDs. Vor dem Abruf muss die ID
von einem gespeicherten Defender-for-IoT-Finding referenziert werden, sodass
der Endpunkt nicht als allgemeiner externer Proxy verwendet werden kann.
- Antworten besitzen ein 8-MiB-Limit und ein konfigurierbares Timeout. Es werden
nur normalisierte öffentliche CVE-Felder gespeichert; optionale NVD-API-Keys
kommen ausschließlich aus `NVD_API_KEY`, werden nicht persistiert,
zurückgegeben oder protokolliert.
- Von NVD gelieferte Referenzziele gelten als nicht vertrauenswürdig. Es werden
nur `http:`/`https:`-Adressen übernommen und mit `target="_blank"` sowie
`rel="noreferrer"` gerendert.
## Batchimport-Grenze
Mehrdateiimporte verwenden eine serverseitige Vorschau mit eindeutiger Batch- und Eintrags-ID. Der Abschluss enthält nur die ausgewählten IDs, SHA-256-Werte und Dateimetadaten; das Backend gleicht sie gegen die von ihm selbst geparste Vorschau ab. Geänderte, unbekannte, verdrängte oder nach 15 Minuten beziehungsweise einem Neustart abgelaufene Auswahlen werden vor jeder Persistenz abgewiesen. Der Vorschaucache ist zusätzlich auf 10 Batches und 256 MiB Quelldaten begrenzt.
@ -125,8 +145,9 @@ bleiben unabhängig davon eine Eingabegrenze.
`npm test` führt Modul-, Server-, Parser-, API-, Migrations- und
Repositorytests sowie `tests/local-only-check.mjs` aus. Die Architekturprüfung
erlaubt `fetch` nur im gekapselten Same-Origin-API-Client, verhindert fest
eingetragene externe Ursprünge, CORS-Freigaben, zusätzliche Netzwerkpfade,
erlaubt Browser-`fetch` nur im gekapselten Same-Origin-API-Client und den
freigegebenen Backend-NVD-Client nur mit festem NIST-Ursprung; sie verhindert
CORS-Freigaben, zusätzliche Netzwerkpfade,
externe Assets, erweiterte Tauri-Rechte und Datenbankzugriffe aus React-Modulen.
Der PostgreSQL-Integrationstest verwendet eine ausdrücklich gesetzte

View File

@ -28,6 +28,17 @@ ASSESSOR_POSTGRES_PASSWORD=replace-with-a-long-random-experimental-password
Do not reuse the stable database name, user, or password for experimental.
Secret values must never be printed into an Actions log.
Optional CVE enrichment settings may be added to each environment secret:
```text
NVD_API_KEY=<optional NVD API key for higher rate limits>
ASSESSOR_NVD_TIMEOUT_MS=15000
```
The key is sent only by the backend to the fixed NVD CVE 2.0 endpoint. Imports
remain offline and deterministic; a user must start enrichment for an already
stored Defender-for-IoT CVE.
## Mandatory server inventory
Before the first push-triggered deployment, connect to the target server over

View File

@ -877,6 +877,12 @@ Verifizierter Abschlussstand vom 2026-07-17:
- Setup, Migrationen, Suche, Backup, Restore und Löschung sind in `docs/POSTGRESQL.md` dokumentiert.
- 40 Testdateien mit 108 Tests einschließlich realem PostgreSQL-16-Integrationstest, Same-Origin-/Local-only-Sicherheitsprüfung, Produktionsbuild und gestartetem PostgreSQL-Backend laufen erfolgreich.
Verifizierte Erweiterung vom 2026-09-10:
- Migration 021 und beide Repository-Adapter fuehren einen tenant- und timeslotunabhaengigen CVE-Referenzbestand ein. Ein normalisierter CVE-Schluessel wird mit NVD-Beschreibung, Metriken, Schwachstellenklassen, Referenzen, betroffenen Konfigurationen, Quellzeitpunkten und Provenienz genau einmal gespeichert und in alle passenden Defender-Vorkommen projiziert.
- Import und Enrichment bleiben getrennte Schritte: Der Defender-Import erzeugt weiterhin deterministisch nur seine quellengetreuen Findings. Erst eine ausdrueckliche Backendaktion fuer eine bereits gespeicherte Defender-CVE ruft den festen NVD-CVE-2.0-Endpunkt auf und aktualisiert den gemeinsamen Referenzbestand; ein spaeter importierter Mandant mit derselben CVE erhaelt die vorhandene Anreicherung ohne erneuten Abruf.
- Repository-, Parser-, API-Client-, Migrations- und Sicherheitsregressionen sind synthetisch verifiziert. 45 fokussierte Tests, der vollstaendige Standardtestlauf und Client-/Server-Produktionsbuild sind erfolgreich. Migration 021 wurde zusaetzlich gegen die konfigurierte PostgreSQL-Instanz angewendet und ein Backendstart per HTTP-200-Healthsignal bestaetigt. Die Nummer 021 respektiert die dort bereits append-only belegten Versionen 019 `native-policy-targets` und 020 `baseline-driven-policy-evaluation`; deren Ledger-Eintraege wurden nicht veraendert. Es wurde weder realer Mandanteninhalt gelesen noch ein Live-NVD-Abruf ausgefuehrt.
Abnahmekriterien:
- Fehlgeschlagene Importe hinterlassen keine Teilbestände.
@ -1365,6 +1371,8 @@ Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04
| Datum | Entscheidung | Begründung |
|---|---|---|
| 2026-09-10 | NVD `configurations` bleiben als top-level Konfigurationsgruppen erhalten. Vulnerable CPE-Matches bilden die betroffene Software je Gruppe; nicht vulnerable Matches derselben Gruppe werden getrennt als `Running on/with` gespeichert und dargestellt. Das Enrichment-Recordformat ist dafuer Version 2; Version-1-Flachsaetze bleiben lesbar, muessen fuer die verlustfreie Gruppierung aber erneut von NVD abgerufen werden. | Die NVD-Oberflaeche und API beschreiben eine logische Konfiguration aus betroffener Software und ihren Laufzeit-/Hardwarebedingungen. Ein flaches Match-Array ueberzaehlt Konfigurationen und verliert genau diese Beziehung. Die Gruppierung muss deshalb vor der Persistenz im externen Referenzadapter erhalten werden. |
| 2026-09-10 | CVE-Enrichment ist ein globaler, nach normalisierter CVE-ID identifizierter Referenzbestand und weder Bestandteil der tenant-/timeslotgebundenen FindingOccurrence noch der werkzeuggebundenen, durch Reimport ersetzbaren FindingDefinition. Defender-Import und NVD-Enrichment bleiben getrennt; nur eine ausdrueckliche Aktion fuer eine bereits gespeicherte Defender-CVE darf den festen NVD-Endpunkt abfragen. | Beschreibung, CVSS-Metriken, CWE, Referenzen und betroffene Konfigurationen beschreiben die CVE selbst. Ein gemeinsamer Datensatz vermeidet redundante, auseinanderlaufende Kopien und macht vorhandene Anreicherung fuer spaetere Mandanten automatisch wiederverwendbar, waehrend der Import ohne Netzabhaengigkeit reproduzierbar bleibt. |
| 2026-08-25 | Die appweite persistente Markierungs- und Auswahlfarbe ist auf ausdrueckliche Nutzerfestlegung immer Gelb und wird in beiden Themes ueber `--marking-color` gefuehrt. Kachelauswahl, Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl verwenden diesen Token. Blau bleibt Tastaturfokus; Gruen, Rot, Orange und Severitytoene bleiben fachliche Statussignale. | Ein einzelner semantischer Markierungston verhindert violette oder skinabhaengig wechselnde Auswahlkonturen wie im Datahub-Screenshot und bewahrt gleichzeitig die Bedeutung bestehender Statusfarben. |
| 2026-08-25 | Die unmittelbar zuvor fuer Client Info festgelegte flache SOC-Kachel ist auf ausdrueckliche Nutzerfestlegung der neue appweite Kachelstandard: keine sichtbare Normalzustandskontur, kein Radius und kein Schatten; Hover veraendert nur die Flaeche. Fokus, Auswahl und kleine Statusakzente bleiben funktionale Ausnahmen. Die gemeinsamen `--tile-*`-Tokens und Projektregeln tragen diesen Vertrag. | Ein zentraler Standard verhindert, dass neue oder bestehende Ansichten erneut weisse Konturen, Rundungen oder schwebende Schatten einfuehren. Flaechenkontrast und gemeinsamer Abstand erzeugen die gewuenschte ruhige, dichte SOC-Geometrie. |
| 2026-08-25 | Das quadratische Client-Info-Raster uebernimmt die flache Kartenoberflaeche der Nutzerreferenz: regulaere Kennzahl- und Inhaltskacheln haben keine Aussenkontur, keinen Schatten, den zentralen Nullradius und keine dekorative Trennlinie unter dem Kartentitel. Farbige Fokus- und Auswahlkonturen bleiben als funktionale Zustaende erhalten. | In der Referenz werden Karten allein durch Flaechenkontrast und dunkle Zwischenraeume getrennt. Die bisherige helle Kontur, Rundung und Schattenkante erzeugten eine abweichende, unruhige zweite Geometrieebene. |
@ -1633,6 +1641,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
| Risiko | Gegenmaßnahme |
|---|---|
| NVD kann nicht erreichbar oder rate-limitiert sein, unerwartet grosse Antworten liefern oder externe Referenz-URLs enthalten; ein API-Schluessel darf nicht an Browser oder Logs gelangen. Eine automatische Anreicherung waehrend des Imports wuerde zudem dessen Reproduzierbarkeit und Verfuegbarkeit an einen externen Dienst koppeln. | Enrichment ausschliesslich separat und nutzerausgeloest im Backend ausfuehren, nur exakt gespeicherte Defender-CVE-IDs akzeptieren, Zielorigin und API-Pfad fest verdrahten sowie Timeout und 8-MiB-Antwortgrenze erzwingen. Den optionalen Schluessel nur aus `NVD_API_KEY` als Serverheader senden, Antwortfelder normalisieren, Referenzen nur als HTTP(S)-Links darstellen und Importfehler strikt vom Enrichment-Ergebnis trennen. |
| 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. |
@ -1826,6 +1835,13 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 2026-09-10 | Result-DB-Beschreibungsdeduplizierung fuer einzelne Defender-CVEs nachgehaertet: Scanner- und NVD-Beschreibung werden fuer den reinen Anzeigevergleich jetzt Unicode-NFKC-normalisiert, von unsichtbaren Formatzeichen bereinigt, case-insensitiv behandelt und ueber ihre Wort-/Zahlfolge verglichen. Dadurch werden visuell identische Saetze auch bei Soft-Hyphen-, Zeichensetzungs- oder Grossschreibungsabweichungen nicht doppelt gerendert; inhaltlich unterschiedliche Wortfolgen bleiben sichtbar. Gespeicherte Quelltexte bleiben unveraendert. Drei fokussierte Regressionstests und der Client-Produktionsbuild sind erfolgreich. |
| 2026-09-10 | Work-Darstellung der gruppierten NVD-Konfigurationen an die Originalstruktur angenaehert: Jede nummerierte `Configuration` rendert genau eine Tabelle. Nach den betroffenen CPE-Zeilen steht `Running on/with` als eigene Tabellenzeile; die zugeordneten Plattform-CPEs folgen innerhalb derselben Tabelle statt in einer zweiten Tabelle. Gruppierung, Speicherung und Reihenfolge der Findings bleiben unveraendert. 17 fokussierte Finding-/CVE-Darstellungstests und der Client-Produktionsbuild sind erfolgreich. |
| 2026-09-10 | NVD-Konfigurationsprojektion korrigiert: Der NVD-Adapter flacht CPE-Matches nicht mehr zu einzelnen Konfigurationen ab, sondern speichert jede top-level API-Konfiguration als eine Gruppe mit getrennten `vulnerableMatches` und `runningOnWith`. Work zeigt je Gruppe `Configuration n`, die betroffene Software mit Versionsbereich und darunter die zugehoerigen `Running on/with`-CPEs. Das Enrichmentformat ist Version 2; vorhandene Version-1-Daten bleiben defensiv lesbar und werden beim naechsten `Refresh from NVD` verlustfrei ersetzt. 33 fokussierte Parser-, Persistenz- und Darstellungsregressionen, beide TypeScript-Pruefungen sowie der vollstaendige Client-/Server-Produktionsbuild sind erfolgreich. Keine SQL-Migration und keine Aenderung der Scannerfindings. |
| 2026-09-10 | Work-Reader-Reihenfolge fuer `NVD Affected Configurations` nach Nutzerkorrektur vereinfacht: Das Feld bleibt Bestandteil des normalen Finding-Summary-Renders und erhaelt dort lediglich eine niedrigere Sortierprioritaet als die anderen regulaeren Felder. Der kurzzeitig eingefuehrte separate Abschlussabschnitt wurde vollstaendig entfernt. Die sichtbare Tabelle steht damit normal nach `NVD Metrics` und den weiteren NVD-Feldern. 17 fokussierte Finding-/CVE-Tests und der Client-Produktionsbuild sind erfolgreich. Keine Daten-, API- oder Persistenzaenderung. |
| 2026-09-10 | NVD-Betroffenheitskonfigurationen an das Ende des Findings verschoben: In der Work-Projektion ist `NVD Affected Configurations` nun das letzte angereicherte Feld nach `NVD Record`; in der erweiterten Result-DB-Zeile steht die gleich benannte Anzahl nach Referenzen und NVD-Link als letzter Inhalt. Speicherung und Quellwerte bleiben unveraendert. Drei fokussierte CVE-Darstellungsregressionen und der Client-Produktionsbuild sind erfolgreich. |
| 2026-09-10 | Result-DB-Darstellung fuer angereicherte Defender-CVEs korrigiert: Ist die normalisierte NVD-Beschreibung bis auf Leerraum identisch mit der bereits sichtbaren Scannerbeschreibung, wird sie im NVD-Abschnitt nicht erneut gerendert. Inhaltlich abweichende NVD-Beschreibungen bleiben als getrennte Quelle sichtbar; gespeicherte Scanner- und NVD-Daten werden nicht veraendert. Zwei fokussierte Regressionstests und der Client-Produktionsbuild sind erfolgreich. Keine API-, Schema- oder Persistenzaenderung. |
| 2026-09-10 | Separate globale CVE-Anreicherung nach dem Import umgesetzt: Das neue tenantunabhaengige CVE-Modell und Migration 021 speichern normalisierte NVD-Beschreibung, CVSS-Metriken, CWE, Referenzen, betroffene Konfigurationen, Zeitpunkte und Provenienz einmal je CVE. Local- und PostgreSQL-Repository, Assessment-/Export- und Unified-Result-Projektionen verwenden denselben Bestand fuer alle passenden Defender-Vorkommen. Die Same-Origin-API bietet GET sowie eine manuelle Enrich-/Refresh-Mutation; der Results-Browser zeigt und aktualisiert die Daten, ohne Scanner-Originalfelder zu veraendern. Der NVD-Client verwendet ausschliesslich den festen CVE-2.0-Endpunkt, akzeptiert optional `NVD_API_KEY` serverseitig und erzwingt Timeout und 8-MiB-Antwortgrenze. 45 fokussierte synthetische Tests, vollstaendiger `npm test`-Lauf und Client-/Server-Produktionsbuild erfolgreich. Nach erkannter Versionskollision mit den bereits angewandten fremden Ledger-Versionen 019 `native-policy-targets` und 020 `baseline-driven-policy-evaluation` wurde die neue Migration append-only auf die erste freie Version 021 gelegt, gegen die konfigurierte PostgreSQL-Instanz erfolgreich angewendet und der Backendstart per HTTP-200-Healthsignal verifiziert. Kein realer Mandanteninhalt und kein Live-NVD-Abruf. Phasen 9, 11 und 13A bleiben abgeschlossen. |
| 2026-09-10 | Defender-Betroffenheitszahl von der Rolle `score` auf `asset` umgestellt: In ASSESSOR ist `score` die Risikomagnitude (PingCastle bildet darauf seine Points ab und leitet daraus sogar die Severity ab), waehrend die Anzahl betroffener Devices eine Verbreitungskennzahl ist. Das aggregierte CVE traegt die Anzahl daher als `affectedAsset`; der Results-Browser zeigt sie unter der bestehenden Betroffenheitszeile, und `score` bleibt fuer echte Risikoscores frei. Kein Vergleichsschluessel betroffen (weiter `rule-id`). Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. |
| 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl (aus dem Score Affected Device Count) statt eines leeren `Affected Object`, da die CVE keinen einzelnen Asset mehr traegt. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. |
| 2026-09-10 | Defender-Ergebnisfenster auf eine Device-Tabelle umgestellt: Statt der rohen `Source Observations`-JSON zeigt der Result-Button einer CVE nun eine `Affected Devices`-Tabelle (Device, Scanner, Site, eine Zeile je Device), die der Extractor als Markdown-Tabelle liefert und das Ergebnisfenster ueber `RichTextValue` auswertet. Die frueheren Einzelfelder Affected Devices, Reporting Sensors und Site entfallen im Finding (kein Asset-/Kategorie-Mapping mehr; Vergleich weiter ueber `rule-id`), sodass sich Popup und Lesespalte nicht mehr doppeln. Die `Source Observations` bleiben als maschinenlesbare Evidenz erhalten und werden in der Lesespalte ausgeblendet; die Defender-Sonderansicht rekonstruiert ihr Modell unveraendert daraus. Fokussierte Defender-Import-, Dashboard- und Titeltests sowie der Gesamtlauf mit 768 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine SQL- oder Persistenzmigration; Profilversion 1.0.0 bleibt vor Erstfreigabe unveraendert; Phase 13A bleibt abgeschlossen. |

View File

@ -65,6 +65,15 @@ Das Schema besitzt unter anderem:
- B-Tree-Indizes auf Fremdschlüsseln und Filterspalten
- gespeicherte `tsvector`-Dokumente und GIN-Indizes für Findings
- einen GIN-JSONB-Index für spätere strukturierte Zusatzfeldabfragen
- einen globalen CVE-Referenzbestand aus Migration `021_cve_enrichment.sql`,
eindeutig nach normalisierter CVE-ID und bewusst ohne Projekt-, Tenant- oder
Timeslot-Fremdschlüssel
NVD-Anreicherungen werden nicht innerhalb einer Importtransaktion abgerufen.
Eine separate, ausdrückliche Backendaktion aktualisiert den globalen
CVE-Referenzdatensatz. Assessment- und Unified-Result-Abfragen verbinden ihn
anschließend mit jedem passenden Defender-Vorkommen; Projektlöschung entfernt
diesen wiederverwendbaren Referenzbestand nicht.
Importdaten werden zunächst im Prozess gestagt. Der Commit öffnet genau eine
Datenbanktransaktion auf genau einer Poolverbindung, sperrt die

View File

@ -25,6 +25,8 @@ import type { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service
import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import type { CveEnrichmentService } from './cve/cve-enrichment-service';
import { NvdCveClientError } from './cve/nvd-cve-client';
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
import { directImportExecutor, type ImportExecutor } from './import-executor';
@ -130,6 +132,7 @@ export interface AssessorHttpServerOptions {
gpoPolicyFindingService?: GpoPolicyFindingService;
gpoPolicyAnalysisOverviewService?: GpoPolicyAnalysisOverviewService;
microsoftSecurityBaselineService?: MicrosoftSecurityBaselineService;
cveEnrichmentService?: CveEnrichmentService;
}
class HttpError extends Error {
@ -333,6 +336,10 @@ function statusForError(error: unknown): { status: number; code: string; message
if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message };
if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message };
if (error instanceof RepositoryValidationError) return { status: 400, code: 'repository_validation', message: error.message };
if (error instanceof NvdCveClientError) {
const status = error.code === 'invalid_cve_id' ? 400 : error.code === 'nvd_cve_not_found' ? 404 : 502;
return { status, code: error.code, message: error.message };
}
return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' };
}
@ -881,6 +888,22 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (!result) throw new RepositoryNotFoundError(`Result ${segments[1]} ist nicht vorhanden.`);
status = 200; sendJson(response, status, result, requestId); return;
}
if (segments.length === 2 && segments[0] === 'cves' && method === 'GET') {
const record = await repository.getCveEnrichment(segments[1]);
if (!record) throw new RepositoryNotFoundError(`CVE enrichment ${segments[1]} is not available.`);
status = 200; sendJson(response, status, { version: 1, record }, requestId); return;
}
if (segments.length === 3 && segments[0] === 'cves' && segments[2] === 'enrich' && method === 'POST') {
if (!options.cveEnrichmentService) throw new HttpError(503, 'cve_enrichment_unavailable', 'CVE enrichment is not configured.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
try {
const record = await options.cveEnrichmentService.enrich(segments[1]);
status = 200; sendJson(response, status, { version: 1, record }, requestId); return;
} catch (error) {
if (error instanceof NvdCveClientError) throw error;
throw new HttpError(400, 'cve_enrichment_rejected', error instanceof Error ? error.message : 'CVE enrichment was rejected.');
}
}
if (segments.length === 1 && segments[0] === 'findings' && method === 'GET') {
status = 200; sendJson(response, status, await repository.findOccurrences(queryFrom(url)), requestId); return;
}

View File

@ -0,0 +1,22 @@
import type { CveEnrichmentRecord } from '../../src/domain/cve-enrichment';
import { normalizeCveId } from '../../src/domain/cve-enrichment';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import type { NvdCveClient } from './nvd-cve-client';
export class CveEnrichmentService {
constructor(private readonly repository: AssessorRepository, private readonly nvd: Pick<NvdCveClient, 'fetchCve'>) {}
async enrich(value: string): Promise<CveEnrichmentRecord> {
const cveId = normalizeCveId(value);
if (!cveId) throw new Error('A valid CVE identifier is required.');
const occurrences = await this.repository.findOccurrences({
toolId: 'defender-for-iot-vulnerabilities', search: cveId, limit: 10_000
});
const referenced = occurrences.items.some(item =>
normalizeCveId(item.sourceIdentity.canonicalRuleId ?? item.sourceIdentity.originalRuleId ?? '') === cveId);
if (!referenced) throw new Error(`${cveId} is not referenced by a stored Defender for IoT finding.`);
const record = await this.nvd.fetchCve(cveId);
await this.repository.saveCveEnrichment(record);
return record;
}
}

View File

@ -0,0 +1,213 @@
import type {
CveAffectedConfiguration,
CveConfigurationMatch,
CveEnrichmentRecord,
CveMetric,
CveMetricVersion,
CveReference,
CveWeakness
} from '../../src/domain/cve-enrichment';
import { normalizeCveId } from '../../src/domain/cve-enrichment';
const NVD_CVE_API = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
const MAXIMUM_RESPONSE_BYTES = 8 * 1024 * 1024;
export class NvdCveClientError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = 'NvdCveClientError';
}
}
export interface NvdCveClientOptions {
apiKey?: string;
fetcher?: typeof fetch;
timeoutMs?: number;
now?: () => Date;
}
type JsonObject = Record<string, unknown>;
function object(value: unknown): JsonObject | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : undefined;
}
function array(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function string(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function number(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function english(entries: unknown): string | undefined {
const values = array(entries).map(object).filter((item): item is JsonObject => Boolean(item));
const selected = values.find(item => string(item.lang).toLowerCase() === 'en') ?? values[0];
return selected ? string(selected.value) || undefined : undefined;
}
const metricGroups: Array<[string, CveMetricVersion]> = [
['cvssMetricV40', '4.0'], ['cvssMetricV31', '3.1'], ['cvssMetricV30', '3.0'], ['cvssMetricV2', '2.0']
];
function metrics(value: unknown): CveMetric[] {
const container = object(value);
if (!container) return [];
return metricGroups.flatMap(([key, version]) => array(container[key]).flatMap(entry => {
const item = object(entry);
const data = object(item?.cvssData);
const baseScore = number(data?.baseScore);
const vector = string(data?.vectorString);
if (!item || !data || baseScore === undefined || !vector) return [];
return [{
version,
source: string(item.source) || 'unknown',
...(string(item.type) ? { type: string(item.type) } : {}),
baseScore,
...(string(data.baseSeverity) ? { baseSeverity: string(data.baseSeverity) } : {}),
vector,
...(number(item.exploitabilityScore) === undefined ? {} : { exploitabilityScore: number(item.exploitabilityScore) }),
...(number(item.impactScore) === undefined ? {} : { impactScore: number(item.impactScore) })
} satisfies CveMetric];
}));
}
function references(value: unknown): CveReference[] {
return array(value).slice(0, 2_000).flatMap(entry => {
const item = object(entry);
const url = string(item?.url);
if (!item || !/^https?:\/\//i.test(url)) return [];
return [{ url, ...(string(item.source) ? { source: string(item.source) } : {}), tags: array(item.tags).map(string).filter(Boolean) }];
});
}
function weaknesses(value: unknown): CveWeakness[] {
return array(value).slice(0, 200).flatMap(entry => {
const item = object(entry);
if (!item) return [];
const description = english(item.description);
const id = description?.match(/\bCWE-\d+\b/i)?.[0]?.toUpperCase() ?? description;
if (!id) return [];
return [{ source: string(item.source) || 'unknown', ...(string(item.type) ? { type: string(item.type) } : {}), id, ...(description && description !== id ? { description } : {}) }];
});
}
interface ParsedConfigurationMatch {
vulnerable: boolean;
match: CveConfigurationMatch;
}
function configurationMatches(nodes: unknown): ParsedConfigurationMatch[] {
return array(nodes).flatMap(nodeValue => {
const node = object(nodeValue);
if (!node) return [];
const direct = array(node.cpeMatch).slice(0, 5_000).flatMap(matchValue => {
const match = object(matchValue);
const criteria = string(match?.criteria);
if (!match || !criteria) return [];
return [{ vulnerable: match.vulnerable === true, match: {
criteria,
...(string(match.matchCriteriaId) ? { matchCriteriaId: string(match.matchCriteriaId) } : {}),
...(string(match.versionStartIncluding) ? { versionStartIncluding: string(match.versionStartIncluding) } : {}),
...(string(match.versionStartExcluding) ? { versionStartExcluding: string(match.versionStartExcluding) } : {}),
...(string(match.versionEndIncluding) ? { versionEndIncluding: string(match.versionEndIncluding) } : {}),
...(string(match.versionEndExcluding) ? { versionEndExcluding: string(match.versionEndExcluding) } : {})
} satisfies CveConfigurationMatch }];
});
return [...direct, ...configurationMatches(node.children)];
});
}
function affectedConfiguration(value: unknown): CveAffectedConfiguration | undefined {
const configuration = object(value);
if (!configuration) return undefined;
const matches = configurationMatches(configuration.nodes);
if (!matches.length) return undefined;
return {
operator: string(configuration.operator).toUpperCase() === 'AND' ? 'AND' : 'OR',
negate: configuration.negate === true,
vulnerableMatches: matches.filter(item => item.vulnerable).map(item => item.match),
runningOnWith: matches.filter(item => !item.vulnerable).map(item => item.match)
};
}
export function parseNvdCveResponse(payload: unknown, requestedCveId: string, fetchedAt: string): CveEnrichmentRecord {
const root = object(payload);
const vulnerabilities = array(root?.vulnerabilities);
const cve = object(object(vulnerabilities[0])?.cve);
const cveId = normalizeCveId(string(cve?.id));
if (!root || vulnerabilities.length !== 1 || !cve || cveId !== requestedCveId) {
throw new NvdCveClientError('nvd_cve_not_found', `NVD returned no exact record for ${requestedCveId}.`);
}
const publishedAt = string(cve.published);
const lastModifiedAt = string(cve.lastModified);
if (!Number.isFinite(Date.parse(publishedAt)) || !Number.isFinite(Date.parse(lastModifiedAt))) {
throw new NvdCveClientError('nvd_invalid_response', 'NVD returned invalid vulnerability timestamps.');
}
const configurations = array(cve.configurations).slice(0, 2_000)
.map(affectedConfiguration)
.filter((item): item is CveAffectedConfiguration => Boolean(item));
return {
version: 2,
cveId,
source: 'nvd',
sourceIdentifier: string(cve.sourceIdentifier) || 'unknown',
sourceUrl: `https://nvd.nist.gov/vuln/detail/${cveId.toLowerCase()}`,
apiVersion: string(root.version) || '2.0',
vulnerabilityStatus: string(cve.vulnStatus) || 'unknown',
publishedAt,
lastModifiedAt,
fetchedAt,
...(english(cve.descriptions) ? { description: english(cve.descriptions) } : {}),
metrics: metrics(cve.metrics),
weaknesses: weaknesses(cve.weaknesses),
references: references(cve.references),
affectedConfigurations: configurations,
attributes: {
...(cve.cisaExploitAdd ? { cisaExploitAdd: string(cve.cisaExploitAdd) } : {}),
...(cve.cisaActionDue ? { cisaActionDue: string(cve.cisaActionDue) } : {}),
...(cve.cisaRequiredAction ? { cisaRequiredAction: string(cve.cisaRequiredAction) } : {}),
...(cve.cisaVulnerabilityName ? { cisaVulnerabilityName: string(cve.cisaVulnerabilityName) } : {})
}
};
}
export class NvdCveClient {
private readonly fetcher: typeof fetch;
private readonly timeoutMs: number;
private readonly now: () => Date;
constructor(private readonly options: NvdCveClientOptions = {}) {
this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
this.timeoutMs = options.timeoutMs ?? 15_000;
this.now = options.now ?? (() => new Date());
}
async fetchCve(value: string): Promise<CveEnrichmentRecord> {
const cveId = normalizeCveId(value);
if (!cveId) throw new NvdCveClientError('invalid_cve_id', 'A valid CVE identifier is required.');
const url = new URL(NVD_CVE_API);
url.searchParams.set('cveId', cveId);
const headers = new Headers({ Accept: 'application/json' });
if (this.options.apiKey?.trim()) headers.set('apiKey', this.options.apiKey.trim());
let response: Response;
try {
response = await this.fetcher(url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
} catch (error) {
throw new NvdCveClientError('nvd_unavailable', error instanceof Error ? error.message : 'NVD request failed.');
}
if (!response.ok) throw new NvdCveClientError('nvd_http_error', `NVD request failed with HTTP ${response.status}.`);
const declaredLength = Number(response.headers.get('content-length') ?? 0);
if (declaredLength > MAXIMUM_RESPONSE_BYTES) throw new NvdCveClientError('nvd_response_too_large', 'NVD response exceeds the configured size limit.');
const body = await response.text();
if (Buffer.byteLength(body, 'utf8') > MAXIMUM_RESPONSE_BYTES) throw new NvdCveClientError('nvd_response_too_large', 'NVD response exceeds the configured size limit.');
let payload: unknown;
try { payload = JSON.parse(body); }
catch { throw new NvdCveClientError('nvd_invalid_response', 'NVD returned invalid JSON.'); }
return parseNvdCveResponse(payload, cveId, this.now().toISOString());
}
}

View File

@ -15,6 +15,8 @@ import { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service';
import { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
import { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import { NvdCveClient } from './cve/nvd-cve-client';
import { CveEnrichmentService } from './cve/cve-enrichment-service';
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
if (value === undefined || value.trim() === '') return fallback;
@ -66,6 +68,10 @@ async function main(): Promise<void> {
const gpoPolicyEvaluationService = new GpoPolicyEvaluationService(runtime.repository);
const gpoPolicyFindingService = new GpoPolicyFindingService(runtime.repository);
const gpoPolicyAnalysisOverviewService = new GpoPolicyAnalysisOverviewService(runtime.repository);
const cveEnrichmentService = new CveEnrichmentService(runtime.repository, new NvdCveClient({
apiKey: process.env.NVD_API_KEY?.trim() || undefined,
timeoutMs: boundedInteger(process.env.ASSESSOR_NVD_TIMEOUT_MS, 15_000, 1_000, 60_000, 'ASSESSOR_NVD_TIMEOUT_MS')
}));
await microsoftSecurityBaselineService.refreshDetectedMetadata();
const baselineBackfill = await microsoftSecurityBaselineService.reprocessPending();
if (baselineBackfill.failed) console.warn('Microsoft-Baseline-Backfill:', baselineBackfill.analyzed,
@ -101,6 +107,7 @@ async function main(): Promise<void> {
gpoPolicyEvaluationService,
gpoPolicyFindingService,
gpoPolicyAnalysisOverviewService,
cveEnrichmentService,
importExecutor
});
server.listen(parsedPort, host, () => {

View File

@ -16,6 +16,7 @@ import policyEvaluations from './migrations/015_policy_evaluations.sql?raw';
import policyEvaluationRuleCatalogs from './migrations/016_policy_evaluation_rule_catalogs.sql?raw';
import policyFindingFactory from './migrations/017_policy_finding_factory.sql?raw';
import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw';
import cveEnrichment from './migrations/021_cve_enrichment.sql?raw';
export interface DatabaseMigration {
version: string;
@ -41,5 +42,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '015', name: 'policy-evaluations', sql: policyEvaluations },
{ version: '016', name: 'policy-evaluation-rule-catalogs', sql: policyEvaluationRuleCatalogs },
{ version: '017', name: 'policy-finding-factory', sql: policyFindingFactory },
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
{ version: '021', name: 'cve-enrichment', sql: cveEnrichment }
];

View File

@ -0,0 +1,16 @@
CREATE TABLE assessor_cve_enrichments (
cve_id text PRIMARY KEY CHECK (cve_id ~ '^CVE-[0-9]{4}-[0-9]{4,}$'),
source text NOT NULL CHECK (source = 'nvd'),
last_modified_at timestamptz NOT NULL,
fetched_at timestamptz NOT NULL,
data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object')
);
CREATE INDEX assessor_cve_enrichments_last_modified_idx
ON assessor_cve_enrichments (last_modified_at DESC);
CREATE INDEX assessor_cve_enrichments_search_idx
ON assessor_cve_enrichments USING gin (
to_tsvector('simple'::regconfig,
coalesce(data->>'cveId', '') || ' ' || coalesce(data->>'description', ''))
);

View File

@ -24,6 +24,8 @@ import { storedPolicyEvaluationValidationErrors } from '../../src/domain/policy-
import type { StoredPolicyFindingRun } from '../../src/domain/policy-finding';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../../src/domain/environment-context';
import { environmentCoverage } from '../../src/domain/environment-context';
import type { CveEnrichmentRecord } from '../../src/domain/cve-enrichment';
import { cveEnrichmentValidationErrors, cveIdFromRuleId, normalizeCveId } from '../../src/domain/cve-enrichment';
import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding';
import {
environmentProfileValidationErrors,
@ -345,6 +347,7 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record<string, DbStructureLayerId> = {
assessor_asset_edges: 'canonical',
assessor_edge_coverage: 'canonical',
assessor_finding_definitions: 'result',
assessor_cve_enrichments: 'result',
assessor_finding_occurrences: 'result',
assessor_result_categories: 'result',
assessor_finding_occurrence_categories: 'result',
@ -518,6 +521,16 @@ export class PostgresAssessorRepository implements AssessorRepository {
`SELECT o.data FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY o.id`, [assessmentId]
);
const occurrenceRows = dataRows(occurrences);
const cveIds = [...new Set(occurrenceRows.flatMap(occurrence => {
const id = cveIdFromRuleId(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId);
return id ? [id] : [];
}))];
const cveEnrichments = cveIds.length
? await queryable.query<DataRow<CveEnrichmentRecord>>(
'SELECT data FROM assessor_cve_enrichments WHERE cve_id = ANY($1::text[]) ORDER BY cve_id', [cveIds]
)
: null;
const evidence = await queryable.query<DataRow<EvidenceRecord>>(
`SELECT e.data FROM assessor_evidence e JOIN assessor_finding_occurrences o ON o.id = e.occurrence_id JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY e.id`, [assessmentId]
@ -537,7 +550,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
schemaVersion: 1, project, assessment,
scanRuns: dataRows(runs), importFiles: dataRows(files), sourceTables: dataRows(tables),
reportSections: dataRows(reportSections), environmentObservations: dataRows(environmentObservations),
findingDefinitions: dataRows(definitions), findingOccurrences: dataRows(occurrences),
findingDefinitions: dataRows(definitions), findingOccurrences: occurrenceRows,
cveEnrichments: cveEnrichments ? dataRows(cveEnrichments) : [],
evidence: dataRows(evidence), reviews: dataRows(reviews), diagnostics: dataRows(diagnostics)
};
}
@ -546,6 +560,38 @@ export class PostgresAssessorRepository implements AssessorRepository {
return this.assessmentModel(this.pool, assessmentId);
}
async getCveEnrichment(value: string): Promise<CveEnrichmentRecord | null> {
const cveId = normalizeCveId(value);
if (!cveId) return null;
return dataRow(await this.pool.query<DataRow<CveEnrichmentRecord>>(
'SELECT data FROM assessor_cve_enrichments WHERE cve_id = $1', [cveId]
));
}
async listCveEnrichments(values: string[]): Promise<CveEnrichmentRecord[]> {
const cveIds = [...new Set(values.map(normalizeCveId).filter((value): value is string => Boolean(value)))];
if (!cveIds.length) return [];
return dataRows(await this.pool.query<DataRow<CveEnrichmentRecord>>(
'SELECT data FROM assessor_cve_enrichments WHERE cve_id = ANY($1::text[]) ORDER BY cve_id', [cveIds]
));
}
async saveCveEnrichment(record: CveEnrichmentRecord): Promise<void> {
const errors = cveEnrichmentValidationErrors(record);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
await this.mutate(async client => {
await client.query(`
INSERT INTO assessor_cve_enrichments (cve_id, source, last_modified_at, fetched_at, data)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (cve_id) DO UPDATE SET
source = EXCLUDED.source,
last_modified_at = EXCLUDED.last_modified_at,
fetched_at = EXCLUDED.fetched_at,
data = EXCLUDED.data
`, [record.cveId, record.source, record.lastModifiedAt, record.fetchedAt, json(record)]);
});
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -918,6 +964,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
definition: FindingDefinition;
occurrence: FindingOccurrence;
reviews: FindingReview[];
cve_enrichment: CveEnrichmentRecord | null;
} & QueryResultRow>(`
SELECT
p.data AS project,
@ -926,6 +973,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
f.data AS import_file,
d.data AS definition,
o.data AS occurrence,
cve.data AS cve_enrichment,
COALESCE((
SELECT jsonb_agg(rv.data ORDER BY rv.id)
FROM assessor_reviews rv
@ -937,6 +985,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
JOIN assessor_assessments a ON a.id = r.assessment_id
JOIN assessor_projects p ON p.id = a.project_id
JOIN assessor_import_files f ON f.id = o.import_file_id
LEFT JOIN assessor_cve_enrichments cve
ON cve.cve_id = upper(COALESCE(o.data #>> '{sourceIdentity,canonicalRuleId}', o.data #>> '{sourceIdentity,originalRuleId}', ''))
${where}
ORDER BY o.scan_run_id, o.id
`, values);
@ -947,7 +997,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
importFile: clone(row.import_file),
definition: clone(row.definition),
occurrence: clone(row.occurrence),
reviews: clone(row.reviews)
reviews: clone(row.reviews),
...(row.cve_enrichment ? { cveEnrichment: clone(row.cve_enrichment) } : {})
}));
}
@ -2111,6 +2162,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
environmentObservations: models.flatMap(model => model.environmentObservations ?? []),
findingDefinitions: [...uniqueDefinitions.values()].sort((left, right) => left.id.localeCompare(right.id)),
findingOccurrences: models.flatMap(model => model.findingOccurrences),
cveEnrichments: [...new Map(models.flatMap(model => model.cveEnrichments ?? []).map(item => [item.cveId, item])).values()]
.sort((left, right) => left.cveId.localeCompare(right.cveId)),
evidence: models.flatMap(model => model.evidence),
reviews: await this.listReviews(projectId),
diagnostics: models.flatMap(model => model.diagnostics),

View File

@ -45,7 +45,8 @@ import {
type MicrosoftSecurityBaselineDeletionResponse,
type MicrosoftSecurityBaselineSelectionResponse,
type MicrosoftSecurityBaselineAnalysisResponse,
type MicrosoftSecurityBaselineAnalysisListResponse
type MicrosoftSecurityBaselineAnalysisListResponse,
type CveEnrichmentResponse
} from './assessor-api-contract';
export class AssessorApiError extends Error {
@ -179,6 +180,16 @@ export class AssessorApiClient {
return this.request(`/results/${encodeURIComponent(resultProjectionId)}`);
}
getCveEnrichment(cveId: string): Promise<CveEnrichmentResponse> {
return this.request(`/cves/${encodeURIComponent(cveId)}`);
}
enrichCve(cveId: string): Promise<CveEnrichmentResponse> {
return this.request(`/cves/${encodeURIComponent(cveId)}/enrich`, {
method: 'POST', body: JSON.stringify({})
});
}
listResultCategories(query: UnifiedResultQuery = {}): Promise<UnifiedResultCategory[]> {
return this.request(unifiedQueryPath('/results/categories', query));
}

View File

@ -14,6 +14,7 @@ import type { CreatePolicyEvaluationRequest, StoredPolicyEvaluation } from '../d
import type { CreatePolicyFindingRunRequest, StoredPolicyFindingRun } from '../domain/policy-finding';
import type { PolicyEvaluationRuleCatalog } from '../domain/policy-evaluation-rules';
import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview';
import type { CveEnrichmentRecord } from '../domain/cve-enrichment';
export const ASSESSOR_API_PREFIX = '/api/v1';
export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';
@ -35,6 +36,8 @@ export interface AssessorApiErrorBody {
};
}
export interface CveEnrichmentResponse { version: 1; record: CveEnrichmentRecord; }
export interface ImportSourceValidationRequest {
fileName: string;
sizeBytes: number;

View File

@ -1,5 +1,6 @@
import type { ResultContractExposureState, ResultContractResultType } from './result-contract-mapping';
import type { EnvironmentObservation, ScannerReportSection } from './environment-context';
import type { CveEnrichmentRecord } from './cve-enrichment';
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
@ -330,9 +331,10 @@ export interface CanonicalAssessmentModel {
environmentObservations?: EnvironmentObservation[];
findingDefinitions: FindingDefinition[];
findingOccurrences: FindingOccurrence[];
/** Shared, tenant-independent CVE knowledge referenced by this assessment. */
cveEnrichments?: CveEnrichmentRecord[];
evidence: EvidenceRecord[];
reviews: FindingReview[];
diagnostics: ImportDiagnostic[];
}

View File

@ -0,0 +1,100 @@
import type { JsonValue } from './canonical-model';
export type CveMetricVersion = '2.0' | '3.0' | '3.1' | '4.0';
export interface CveMetric {
version: CveMetricVersion;
source: string;
type?: string;
baseScore: number;
baseSeverity?: string;
vector: string;
exploitabilityScore?: number;
impactScore?: number;
}
export interface CveReference {
url: string;
source?: string;
tags: string[];
}
export interface CveWeakness {
source: string;
type?: string;
id: string;
description?: string;
}
export interface CveConfigurationMatch {
criteria: string;
matchCriteriaId?: string;
versionStartIncluding?: string;
versionStartExcluding?: string;
versionEndIncluding?: string;
versionEndExcluding?: string;
}
export interface CveAffectedConfiguration {
operator: 'AND' | 'OR';
negate: boolean;
vulnerableMatches: CveConfigurationMatch[];
runningOnWith: CveConfigurationMatch[];
}
/** Version-1 records flattened NVD matches and remain readable until refreshed. */
export interface LegacyCveAffectedConfiguration extends CveConfigurationMatch {
vulnerable: boolean;
}
/**
* Tenant-independent public vulnerability knowledge. Scanner occurrences keep
* their own source values; this record is a separately refreshable projection
* of one external authority keyed only by the normalized CVE identifier.
*/
export interface CveEnrichmentRecord {
version: 1 | 2;
cveId: string;
source: 'nvd';
sourceIdentifier: string;
sourceUrl: string;
apiVersion: string;
vulnerabilityStatus: string;
publishedAt: string;
lastModifiedAt: string;
fetchedAt: string;
description?: string;
metrics: CveMetric[];
weaknesses: CveWeakness[];
references: CveReference[];
affectedConfigurations: Array<CveAffectedConfiguration | LegacyCveAffectedConfiguration>;
/** Preserves small NVD additions that ASSESSOR does not yet normalize. */
attributes: Record<string, JsonValue>;
}
const cvePattern = /^CVE-\d{4}-\d{4,}$/;
export function normalizeCveId(value: string): string | null {
const normalized = value.trim().toLocaleUpperCase('en-US');
return cvePattern.test(normalized) ? normalized : null;
}
export function cveIdFromRuleId(value: string | undefined): string | null {
return value ? normalizeCveId(value) : null;
}
export function cveEnrichmentValidationErrors(record: CveEnrichmentRecord): string[] {
const errors: string[] = [];
if (normalizeCveId(record.cveId) !== record.cveId) errors.push('CVE ID must be normalized.');
if (record.source !== 'nvd') errors.push('Only NVD enrichment is supported.');
if (!record.sourceIdentifier.trim()) errors.push('NVD source identifier is required.');
if (!record.sourceUrl.startsWith('https://nvd.nist.gov/vuln/detail/')) errors.push('NVD detail URL is invalid.');
if (![record.publishedAt, record.lastModifiedAt, record.fetchedAt].every(value => Number.isFinite(Date.parse(value)))) {
errors.push('CVE timestamps must be valid ISO dates.');
}
if (record.metrics.some(metric => !Number.isFinite(metric.baseScore) || metric.baseScore < 0 || metric.baseScore > 10 || !metric.vector.trim())) {
errors.push('CVSS metrics contain an invalid score or vector.');
}
if (record.references.some(reference => !/^https?:\/\//i.test(reference.url))) errors.push('CVE reference URL is invalid.');
return errors;
}

View File

@ -22,6 +22,7 @@ import { deriveExposureState, deriveIsIoe, deriveResultType } from './result-con
import { isDeviation } from './deviation-predicate';
import { severityRank } from './severity-order';
import { processingReadiness, type ProcessingReadinessStatus } from './processing-compatibility';
import type { CveEnrichmentRecord } from './cve-enrichment';
export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'environment-observation';
export type UnifiedOutcome = CanonicalOutcome;
@ -121,6 +122,7 @@ export interface UnifiedResultProjection {
evaluatedAt?: string;
createdAt: string;
categories: UnifiedResultCategory[];
cveEnrichment?: CveEnrichmentRecord;
}
export interface UnifiedResultQuery {
@ -189,6 +191,7 @@ export interface ScannerUnifiedResultSource {
definition: FindingDefinition;
occurrence: FindingOccurrence;
reviews: FindingReview[];
cveEnrichment?: CveEnrichmentRecord;
}
export interface PolicyUnifiedResultSource {
@ -348,7 +351,8 @@ export function createScannerUnifiedResult(source: ScannerUnifiedResultSource):
sourceObservedAt: source.occurrence.observedAt,
importedAt: source.importFile.importedAt,
createdAt: source.importFile.importedAt,
categories: scannerResultCategories(source.definition, source.occurrence, source.importFile)
categories: scannerResultCategories(source.definition, source.occurrence, source.importFile),
...(source.cveEnrichment ? { cveEnrichment: source.cveEnrichment } : {})
};
}

View File

@ -218,6 +218,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
if (toolId === 'pingcastle' && normalized === 'documentation') return 5;
if (/^remediation(?: guidance| recommendation)?$/.test(normalized)) return 3;
if (/^(source|quelle)(?:\s*:\s*.*)?$/.test(normalized)) return 4;
if (normalized === 'nvd affected configurations') return 11;
return 10;
};
const summaryValues = new Set<string>();
@ -358,4 +359,3 @@ export function FindingReader({ record, title, review: storedReview, linkedSheet
</article>
);
}

View File

@ -4,6 +4,8 @@ import { rowsToCsv } from '../../exports/csv-export';
import { makeSheet } from '../../importers/sheet-factory';
import { stableSheetId } from '../../shared/identifiers';
import { groupProjectImportFiles } from './datahub-import-groups';
import type { CveAffectedConfiguration, CveConfigurationMatch, CveEnrichmentRecord } from '../../domain/cve-enrichment';
import { cveIdFromRuleId } from '../../domain/cve-enrichment';
function text(value: JsonValue): string {
if (value === null) return '';
@ -92,10 +94,80 @@ function withDerivedSeverity(fields: OriginalField[], occurrence: FindingOccurre
return [...fields, { key: 'derived-severity', label: 'Severity', value: derived, path: 'derived/severity' }];
}
function displayFieldsByOccurrence(occurrences: FindingOccurrence[]): Map<string, OriginalField[]> {
function markdownCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ').trim();
}
export function cveFieldsForDisplay(record: CveEnrichmentRecord): OriginalField[] {
const fields: OriginalField[] = [];
const add = (key: string, label: string, value: string) => {
if (value) fields.push({ key, label, value, path: `derived/cve/${key}` });
};
add('nvd-description', 'NVD Description', record.description ?? '');
add('nvd-metrics', 'NVD Metrics', record.metrics.length ? [
'| Version | Source | Score | Severity | Vector |',
'| --- | --- | ---: | --- | --- |',
...record.metrics.map(metric => `| ${markdownCell(metric.version)} | ${markdownCell(metric.source)} | ${metric.baseScore} | ${markdownCell(metric.baseSeverity ?? '')} | ${markdownCell(metric.vector)} |`)
].join('\n') : '');
add('nvd-weaknesses', 'NVD Weaknesses', record.weaknesses.map(item =>
`- ${item.id}${item.description ? ` — ${item.description}` : ''} (${item.source})`
).join('\n'));
add('nvd-references', 'NVD References', record.references.length ? [
'| Reference | Source | Tags |',
'| --- | --- | --- |',
...record.references.map(reference => `| [${markdownCell(reference.url)}](${reference.url}) | ${markdownCell(reference.source ?? '')} | ${markdownCell(reference.tags.join(', '))} |`)
].join('\n') : '');
add('nvd-dates', 'NVD Record', `Published ${record.publishedAt} · Last modified ${record.lastModifiedAt} · Retrieved ${record.fetchedAt} · [Open NVD](${record.sourceUrl})`);
const groups: CveAffectedConfiguration[] = record.affectedConfigurations.map(configuration =>
'vulnerableMatches' in configuration ? configuration : {
operator: 'OR',
negate: false,
vulnerableMatches: configuration.vulnerable ? [configuration] : [],
runningOnWith: configuration.vulnerable ? [] : [configuration]
}
);
const versionRange = (match: CveConfigurationMatch) => [
match.versionStartIncluding && `from ${match.versionStartIncluding} (including)`,
match.versionStartExcluding && `from ${match.versionStartExcluding} (excluding)`,
match.versionEndIncluding && `to ${match.versionEndIncluding} (including)`,
match.versionEndExcluding && `to ${match.versionEndExcluding} (excluding)`
].filter(Boolean).join(' ');
const matchRows = (matches: CveConfigurationMatch[]) => matches.map(match =>
`| ${markdownCell(match.criteria)} | ${markdownCell(versionRange(match))} |`
);
const visibleConfigurations = groups.slice(0, 100);
add('nvd-configurations', 'NVD Affected Configurations', visibleConfigurations.length ? [
...visibleConfigurations.flatMap((configuration, index) => [
`### Configuration ${index + 1}`,
'| CPE | Version range |',
'| --- | --- |',
...matchRows(configuration.vulnerableMatches),
...(configuration.runningOnWith.length ? [
'| **Running on/with** | |',
...matchRows(configuration.runningOnWith)
] : []),
''
]),
...(groups.length > visibleConfigurations.length
? [`${groups.length - visibleConfigurations.length} additional configurations`] : [])
].join('\n') : '');
return fields;
}
function displayFieldsByOccurrence(
occurrences: FindingOccurrence[],
enrichments: ReadonlyMap<string, CveEnrichmentRecord>
): Map<string, OriginalField[]> {
return new Map(occurrences.map(occurrence => [
occurrence.id,
withDerivedSeverity(withLinkedSectionIdOnly(occurrence.originalFields, occurrence.importFileId), occurrence)
[
...withDerivedSeverity(withLinkedSectionIdOnly(occurrence.originalFields, occurrence.importFileId), occurrence),
...(() => {
const cveId = cveIdFromRuleId(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId);
const enrichment = cveId ? enrichments.get(cveId) : undefined;
return enrichment ? cveFieldsForDisplay(enrichment) : [];
})()
]
]));
}
@ -235,7 +307,8 @@ export function canonicalModelsToDataset(models: CanonicalAssessmentModel[]): Im
if (bucket) bucket.push(occurrence); else occurrencesByFileId.set(occurrence.importFileId, [occurrence]);
}
}
const displayFields = displayFieldsByOccurrence(occurrences);
const enrichments = new Map(models.flatMap(model => model.cveEnrichments ?? []).map(record => [record.cveId, record]));
const displayFields = displayFieldsByOccurrence(occurrences, enrichments);
const tableSheets: DatasetSheet[] = [];
const supportingSheets: DatasetSheet[] = [];

View File

@ -162,6 +162,8 @@ export function mergeProjectModelsForComparison(models: CanonicalAssessmentModel
sourceTables: uniqueById(models.flatMap(model => model.sourceTables)),
findingDefinitions: uniqueById(models.flatMap(model => model.findingDefinitions)),
findingOccurrences: uniqueById(models.flatMap(model => model.findingOccurrences)),
cveEnrichments: [...new Map(models.flatMap(model => model.cveEnrichments ?? [])
.map(record => [record.cveId, record])).values()],
evidence: uniqueById(models.flatMap(model => model.evidence)),
reviews: uniqueById(models.flatMap(model => model.reviews)),
diagnostics: uniqueById(models.flatMap(model => model.diagnostics))

View File

@ -26,6 +26,11 @@ export function canonicalModelForScanRuns(
const findingOccurrences = model.findingOccurrences.filter(occurrence => selectedIds.has(occurrence.scanRunId));
const occurrenceIds = new Set(findingOccurrences.map(occurrence => occurrence.id));
const definitionIds = new Set(findingOccurrences.map(occurrence => occurrence.definitionId));
const cveIds = new Set(findingOccurrences.flatMap(occurrence => {
const value = occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId;
const normalized = value?.trim().toLocaleUpperCase('en-US');
return normalized && /^CVE-\d{4}-\d{4,}$/.test(normalized) ? [normalized] : [];
}));
return {
...model,
scanRuns,
@ -33,6 +38,7 @@ export function canonicalModelForScanRuns(
sourceTables,
findingDefinitions: model.findingDefinitions.filter(definition => definitionIds.has(definition.id)),
findingOccurrences,
cveEnrichments: model.cveEnrichments?.filter(record => cveIds.has(record.cveId)),
evidence: model.evidence.filter(record => occurrenceIds.has(record.occurrenceId)),
reviews: model.reviews.filter(review => review.target.kind === 'definition'
? definitionIds.has(review.target.definitionId)

View File

@ -8,6 +8,19 @@ const assessorApi = new AssessorApiClient();
const PAGE_SIZE = 50;
const MAX_QUERY_PAGE_SIZE = 10000;
export function shouldShowNvdDescription(scannerDescription: string | undefined, nvdDescription: string | undefined): boolean {
if (!nvdDescription?.trim()) return false;
const comparisonKey = (value: string | undefined) => value
?.normalize('NFKC')
.replace(/\p{Cf}/gu, '')
.toLocaleLowerCase('en-US')
.match(/[\p{L}\p{N}]+/gu)
?.join(' ') ?? '';
const normalizedScanner = comparisonKey(scannerDescription);
const normalizedNvd = comparisonKey(nvdDescription);
return normalizedScanner !== normalizedNvd;
}
type BackendStatus = 'connected' | 'checking' | 'unavailable';
interface ResultsBrowserProps {
backendStatus: BackendStatus;
@ -111,6 +124,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: Results
</div>
{queryResult.items.map(item => <ResultRow key={item.resultProjectionId} item={item}
expanded={expandedId === item.resultProjectionId}
onUpdated={load}
onToggle={() => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)}
</div>}
{queryResult && queryResult.total > queryResult.items.length && <div className="results-browser-load-actions">
@ -122,8 +136,30 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds }: Results
</div>;
}
function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultProjection; expanded: boolean; onToggle(): void }) {
function ResultRow({ item, expanded, onToggle, onUpdated }: {
item: UnifiedResultProjection;
expanded: boolean;
onToggle(): void;
onUpdated(): Promise<void>;
}) {
const environment = item.sourceKind === 'environment-observation';
const cveId = item.sourceProfileId === 'defender-for-iot-vulnerabilities'
&& /^CVE-\d{4}-\d{4,}$/i.test(item.sourceRuleId ?? '') ? item.sourceRuleId!.toUpperCase() : undefined;
const [enriching, setEnriching] = useState(false);
const [enrichmentError, setEnrichmentError] = useState<string | null>(null);
const enrich = async () => {
if (!cveId || enriching) return;
setEnriching(true);
setEnrichmentError(null);
try {
await assessorApi.enrichCve(cveId);
await onUpdated();
} catch (reason) {
setEnrichmentError(reason instanceof Error ? reason.message : 'CVE enrichment failed.');
} finally {
setEnriching(false);
}
};
return <div className={`results-browser-row${expanded ? ' expanded' : ''}`} role="row">
<button className="results-browser-row-toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
<span data-label="Title" className="results-browser-title"><TriangleIcon direction={expanded ? 'down' : 'right'} />{item.title}</span>
@ -166,6 +202,36 @@ function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultProjection
</>}
<li>Categories: {item.categories.map(category => category.categoryLabel).join(', ') || '–'}</li>
</ul>
{cveId && <section className="results-cve-enrichment" aria-label={`${cveId} enrichment`}>
<div className="page-actions-row">
<h4>{cveId} · NVD</h4>
<button className="btn" type="button" disabled={enriching} onClick={() => void enrich()}>
{enriching ? 'Retrieving …' : item.cveEnrichment ? 'Refresh from NVD' : 'Enrich from NVD'}
</button>
</div>
{enrichmentError && <p role="alert">{enrichmentError}</p>}
{item.cveEnrichment ? <>
{shouldShowNvdDescription(item.description, item.cveEnrichment.description)
&& <p>{item.cveEnrichment.description}</p>}
<ul>
<li>Status: {item.cveEnrichment.vulnerabilityStatus}</li>
<li>Published: {item.cveEnrichment.publishedAt} · Last modified: {item.cveEnrichment.lastModifiedAt}</li>
<li>Metrics: {item.cveEnrichment.metrics.map(metric =>
`CVSS ${metric.version} ${metric.baseScore}${metric.baseSeverity ? ` ${metric.baseSeverity}` : ''} (${metric.source})`
).join(' · ') || 'Not supplied'}</li>
<li>Weaknesses: {item.cveEnrichment.weaknesses.map(weakness => weakness.id).join(', ') || 'Not supplied'}</li>
</ul>
{item.cveEnrichment.references.length > 0 && <details>
<summary>Advisories, solutions, and tools ({item.cveEnrichment.references.length.toLocaleString('en-US')})</summary>
<ul>{item.cveEnrichment.references.map(reference => <li key={reference.url}>
<a href={reference.url} target="_blank" rel="noreferrer">{reference.url}</a>
{reference.tags.length ? ` · ${reference.tags.join(', ')}` : ''}
</li>)}</ul>
</details>}
<p><a href={item.cveEnrichment.sourceUrl} target="_blank" rel="noreferrer">Open the NVD record</a></p>
<p><strong>NVD Affected Configurations:</strong> {item.cveEnrichment.affectedConfigurations.length.toLocaleString('en-US')}</p>
</> : <p>No shared NVD enrichment has been stored yet.</p>}
</section>}
</div>}
</div>;
}

View File

@ -31,6 +31,7 @@ import type {
UnifiedResultQueryResult
} from '../domain/unified-result';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context';
import type { CveEnrichmentRecord } from '../domain/cve-enrichment';
export interface FindingOccurrenceQuery {
projectId?: string;
@ -125,6 +126,7 @@ export interface ProjectDataExport {
environmentObservations: EnvironmentObservation[];
findingDefinitions: FindingDefinition[];
findingOccurrences: FindingOccurrence[];
cveEnrichments: CveEnrichmentRecord[];
evidence: EvidenceRecord[];
reviews: FindingReview[];
diagnostics: ImportDiagnostic[];
@ -202,6 +204,9 @@ export interface AssessorRepository {
getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null>;
listResultCategories(query?: UnifiedResultQuery): Promise<UnifiedResultCategory[]>;
getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise<EnvironmentSnapshot>;
getCveEnrichment(cveId: string): Promise<CveEnrichmentRecord | null>;
listCveEnrichments(cveIds: string[]): Promise<CveEnrichmentRecord[]>;
saveCveEnrichment(record: CveEnrichmentRecord): Promise<void>;
saveImportArtifact(artifact: ImportArtifact): Promise<void>;
saveImportArtifactAndEnqueueJob(artifact: ImportArtifact, job: IngestionJob): Promise<ArtifactJobCommitResult>;

View File

@ -24,6 +24,8 @@ import { storedPolicyEvaluationValidationErrors } from '../domain/policy-evaluat
import type { StoredPolicyFindingRun } from '../domain/policy-finding';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context';
import { environmentCoverage } from '../domain/environment-context';
import type { CveEnrichmentRecord } from '../domain/cve-enrichment';
import { cveEnrichmentValidationErrors, cveIdFromRuleId, normalizeCveId } from '../domain/cve-enrichment';
import { storedPolicyFindingRunValidationErrors } from '../domain/policy-finding';
import {
environmentProfileValidationErrors,
@ -88,6 +90,7 @@ interface LocalRepositoryState {
environmentObservations: EnvironmentObservation[];
findingDefinitions: FindingDefinition[];
findingOccurrences: FindingOccurrence[];
cveEnrichments: CveEnrichmentRecord[];
evidence: EvidenceRecord[];
reviews: FindingReview[];
diagnostics: ImportDiagnostic[];
@ -120,6 +123,7 @@ const emptyState = (): LocalRepositoryState => ({
environmentObservations: [],
findingDefinitions: [],
findingOccurrences: [],
cveEnrichments: [],
evidence: [],
reviews: [],
diagnostics: [],
@ -181,6 +185,10 @@ function assessmentModel(state: LocalRepositoryState, assessmentId: string): Can
const findingOccurrences = state.findingOccurrences.filter(occurrence => runIds.has(occurrence.scanRunId));
const occurrenceIds = new Set(findingOccurrences.map(occurrence => occurrence.id));
const definitionIds = referencedDefinitionIds(state, occurrenceIds);
const cveIds = new Set(findingOccurrences.flatMap(occurrence => {
const id = cveIdFromRuleId(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId);
return id ? [id] : [];
}));
state.reviews.forEach(review => {
if (review.target.kind === 'definition') {
const definitionId = review.target.definitionId;
@ -200,6 +208,7 @@ function assessmentModel(state: LocalRepositoryState, assessmentId: string): Can
environmentObservations: byId(environmentObservations).map(clone),
findingDefinitions: byId(state.findingDefinitions.filter(definition => definitionIds.has(definition.id))).map(clone),
findingOccurrences: byId(findingOccurrences).map(clone),
cveEnrichments: state.cveEnrichments.filter(record => cveIds.has(record.cveId)).map(clone),
evidence: byId(state.evidence.filter(record => occurrenceIds.has(record.occurrenceId))).map(clone),
reviews: byId(state.reviews.filter(review => review.target.kind === 'occurrence'
? occurrenceIds.has(review.target.occurrenceId)
@ -330,6 +339,27 @@ export class LocalAssessorRepository implements AssessorRepository {
return assessmentModel(this.state, assessmentId);
}
async getCveEnrichment(value: string): Promise<CveEnrichmentRecord | null> {
const cveId = normalizeCveId(value);
return cveId ? clone(this.state.cveEnrichments.find(record => record.cveId === cveId) ?? null) : null;
}
async listCveEnrichments(values: string[]): Promise<CveEnrichmentRecord[]> {
const ids = new Set(values.map(normalizeCveId).filter((value): value is string => Boolean(value)));
return this.state.cveEnrichments.filter(record => ids.has(record.cveId)).map(clone)
.sort((left, right) => left.cveId.localeCompare(right.cveId));
}
async saveCveEnrichment(record: CveEnrichmentRecord): Promise<void> {
const errors = cveEnrichmentValidationErrors(record);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const next = clone(this.state);
const index = next.cveEnrichments.findIndex(item => item.cveId === record.cveId);
if (index >= 0) next.cveEnrichments[index] = clone(record);
else next.cveEnrichments.push(clone(record));
this.replaceState(next);
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -550,7 +580,10 @@ export class LocalAssessorRepository implements AssessorRepository {
importFile,
definition,
occurrence,
reviews: this.state.reviews
reviews: this.state.reviews,
cveEnrichment: this.state.cveEnrichments.find(item => item.cveId === cveIdFromRuleId(
occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId
))
})];
});
}
@ -1286,6 +1319,10 @@ export class LocalAssessorRepository implements AssessorRepository {
...findingOccurrences.map(item => item.definitionId),
...policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId))
]);
const cveIds = new Set(findingOccurrences.flatMap(item => {
const id = cveIdFromRuleId(item.sourceIdentity.canonicalRuleId ?? item.sourceIdentity.originalRuleId);
return id ? [id] : [];
}));
return clone({
version: 1,
project,
@ -1297,6 +1334,7 @@ export class LocalAssessorRepository implements AssessorRepository {
environmentObservations: byId(this.state.environmentObservations.filter(item => runIds.has(item.scanRunId))),
findingDefinitions: byId(this.state.findingDefinitions.filter(item => definitionIds.has(item.id))),
findingOccurrences: byId(findingOccurrences),
cveEnrichments: this.state.cveEnrichments.filter(item => cveIds.has(item.cveId)).map(clone),
evidence: byId(this.state.evidence.filter(item => occurrenceIds.has(item.occurrenceId))),
reviews: await this.listReviews(projectId),
diagnostics: byId(this.state.diagnostics.filter(item => fileIds.has(item.importFileId))),

View File

@ -266,4 +266,19 @@ describe('AssessorApiClient', () => {
projectId: 'project/one', assessmentId: 'assessment/one', cascade: true
});
});
it('reads and refreshes shared CVE enrichment through same-origin endpoints', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
version: 1, record: { cveId: 'CVE-2012-3037' }
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ fetcher });
await client.getCveEnrichment('CVE-2012-3037');
await client.enrichCve('CVE-2012-3037');
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/cves/CVE-2012-3037');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).has('X-Assessor-Request')).toBe(false);
expect(fetcher.mock.calls[1][0]).toBe('/api/v1/cves/CVE-2012-3037/enrich');
expect(fetcher.mock.calls[1][1]?.method).toBe('POST');
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-Assessor-Request')).toBe('1');
});
});

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import type { CveEnrichmentRecord } from '../../src/domain/cve-enrichment';
import { cveFieldsForDisplay } from '../../src/features/project-import/canonical-model-to-dataset';
const enrichment: CveEnrichmentRecord = {
version: 2,
cveId: 'CVE-2012-3037',
source: 'nvd',
sourceIdentifier: 'CVE-2012-3037',
sourceUrl: 'https://nvd.nist.gov/vuln/detail/CVE-2012-3037',
apiVersion: '2.0',
publishedAt: '2012-09-25T11:07:46.470Z',
lastModifiedAt: '2026-06-16T23:42:32.803Z',
fetchedAt: '2026-09-10T10:00:00.000Z',
description: 'Synthetic description.',
vulnerabilityStatus: 'Modified',
metrics: [],
weaknesses: [],
references: [],
affectedConfigurations: [{
operator: 'AND', negate: false,
vulnerableMatches: [{ criteria: 'cpe:2.3:o:example:firmware:*:*:*:*:*:*:*:*' }],
runningOnWith: [{ criteria: 'cpe:2.3:h:example:device:*:*:*:*:*:*:*:*' }]
}],
attributes: {}
};
describe('CVE enrichment display order', () => {
it('places NVD Affected Configurations at the very bottom of the finding fields', () => {
const fields = cveFieldsForDisplay(enrichment);
expect(fields.at(-1)?.label).toBe('NVD Affected Configurations');
expect(fields.findIndex(field => field.label === 'NVD Record'))
.toBeLessThan(fields.findIndex(field => field.label === 'NVD Affected Configurations'));
const value = fields.at(-1)?.value;
expect(typeof value).toBe('string');
expect(value).toContain('### Configuration 1');
expect(value).toContain('| **Running on/with** | |');
expect(String(value).match(/\| CPE \| Version range \|/g)).toHaveLength(1);
});
});

View File

@ -49,6 +49,35 @@ describe('selectFindingFields', () => {
expect(additionalLabels).not.toContain('Duplicate description');
});
it('renders NVD Affected Configurations last in the normal Work finding summary', () => {
const enrichedRecord: FindingRecord = {
...record,
fields: [
...record.fields,
{ index: 11, label: 'NVD Affected Configurations', value: '| CPE | Vulnerable |\n| --- | --- |\n| synthetic | Yes |' },
{ index: 12, label: 'NVD Metrics', value: '| Score |\n| --- |\n| 4.3 |' }
]
};
const selected = selectFindingFields(enrichedRecord, record.title);
expect(selected.summary.at(-1)?.label).toBe('NVD Affected Configurations');
expect(selected.remainingFields.map(field => field.label)).not.toContain('NVD Affected Configurations');
const html = renderToStaticMarkup(createElement(FindingReader, {
record: enrichedRecord,
title: enrichedRecord.title,
linkedSheets: [],
resultReferences: [],
search: '',
position: 0,
recordCount: 1,
onPrevious: () => undefined,
onNext: () => undefined,
onReview: () => undefined
}));
expect(html.indexOf('NVD Affected Configurations')).toBeGreaterThan(html.indexOf('NVD Metrics'));
});
it('keeps generated CIS-CAT Results references out of the finding text', () => {
const cisCatRecord: FindingRecord = {
...record,

View File

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { shouldShowNvdDescription } from '../../src/features/results-browser/ResultsBrowser';
describe('Result DB CVE enrichment', () => {
it('does not repeat the NVD description when the scanner already supplied it', () => {
expect(shouldShowNvdDescription(
'The same vulnerability description is already present.',
' The same vulnerability description\n is already present. '
)).toBe(false);
});
it('ignores invisible Unicode, case, and punctuation differences in equivalent descriptions', () => {
expect(shouldShowNvdDescription(
'Wind River VxWorks 6.7 though 6.9 and vx7 has a Buffer Over\u00adflow in the TCP component (issue 3 of 4).',
'wind river vxworks 6.7 though 6.9 and VX7 has a buffer overflow in the TCP component — issue 3 of 4.'
)).toBe(false);
});
it('keeps a distinct NVD description visible', () => {
expect(shouldShowNvdDescription(
'Scanner-provided description.',
'NVD provides different details.'
)).toBe(true);
});
});

View File

@ -109,6 +109,12 @@ assert.doesNotMatch(httpServer, /Access-Control-Allow-Origin/i, 'the local serve
assert.match(httpServer, /ASSESSOR_MUTATION_HEADER/, 'mutating API requests must retain the same-origin request guard');
assert.match(httpServer, /isValidImportBase64/, 'large Base64 import payloads must use iterative validation');
assert.doesNotMatch(httpServer, /\[A-Za-z0-9\+\/\]\{4\}.*\*/, 'Base64 validation must not use an unbounded repeated-group regex');
const nvdCveClient = read(join('server', 'cve', 'nvd-cve-client.ts'));
assert.match(nvdCveClient, /const NVD_CVE_API = 'https:\/\/services\.nvd\.nist\.gov\/rest\/json\/cves\/2\.0'/,
'the approved NVD client must retain its fixed backend origin');
assert.match(nvdCveClient, /MAXIMUM_RESPONSE_BYTES/, 'NVD responses must remain size-bounded');
assert.match(nvdCveClient, /AbortSignal\.timeout/, 'NVD requests must retain a timeout');
assert.doesNotMatch(nvdCveClient, /new URL\([^N]/, 'the NVD client must not construct request targets from user input');
const postgresRepository = read(join('server', 'postgres', 'postgres-assessor-repository.ts'));
assert.doesNotMatch(postgresRepository, /\\$\\{query\\./, 'PostgreSQL queries must not interpolate fields directly from request queries');
assert.match(postgresRepository, /FOR UPDATE/, 'PostgreSQL mutations must serialize repository revision updates');

View File

@ -359,4 +359,52 @@ describe('LocalAssessorRepository contract', () => {
await expect(repository.listProjectImportFiles(project.id)).resolves.toEqual([]);
await expect(repository.getProject(project.id)).resolves.not.toBeNull();
});
it('reuses one shared CVE enrichment across Defender findings from different tenants', async () => {
const repository = await repositoryWithProject();
const secondProject = { ...project, id: 'project-2', name: 'Project 2' };
const secondAssessment = { ...assessment, id: 'assessment-2', projectId: secondProject.id };
await repository.saveProject(secondProject);
await repository.saveAssessment(secondAssessment);
const firstRun: ScanRun = {
...scanRun(1), id: 'run-defender-1', tool: { id: 'defender-for-iot-vulnerabilities', name: 'Defender for IoT' }
};
const secondRun: ScanRun = {
...firstRun, id: 'run-defender-2', assessmentId: secondAssessment.id
};
const defenderData = (run: ScanRun) => {
const data = importData(run, { occurrenceId: `occurrence-${run.id}`, ruleId: 'CVE-2012-3037' });
data.findingDefinitions[0] = {
...data.findingDefinitions[0], canonicalKey: 'defender-for-iot-vulnerabilities:cve-2012-3037', title: 'CVE-2012-3037'
};
data.importFiles[0] = {
...data.importFiles[0], detectedTool: run.tool,
processedWith: { ...data.importFiles[0].processedWith, profileId: 'defender-for-iot-vulnerabilities' }
};
data.findingOccurrences[0].sourceIdentity.tool = run.tool;
return data;
};
await commitRun(repository, firstRun, defenderData(firstRun));
const session = await repository.beginImport({
sessionId: 'session-defender-2', projectId: secondProject.id, assessmentId: secondAssessment.id, scanRun: secondRun
});
await session.stage(defenderData(secondRun));
await session.commit();
await repository.saveCveEnrichment({
version: 1, cveId: 'CVE-2012-3037', source: 'nvd', sourceIdentifier: 'nvd@nist.gov',
sourceUrl: 'https://nvd.nist.gov/vuln/detail/cve-2012-3037', apiVersion: '2.0', vulnerabilityStatus: 'Deferred',
publishedAt: '2012-08-25T10:55:01.000Z', lastModifiedAt: '2025-04-11T00:51:21.963Z', fetchedAt: timestamp,
description: 'Shared NVD description', metrics: [], weaknesses: [], references: [], affectedConfigurations: [], attributes: {}
});
await expect(repository.queryUnifiedResults({ projectId: project.id })).resolves.toMatchObject({
items: [expect.objectContaining({ cveEnrichment: expect.objectContaining({ cveId: 'CVE-2012-3037', description: 'Shared NVD description' }) })]
});
await expect(repository.queryUnifiedResults({ projectId: secondProject.id })).resolves.toMatchObject({
items: [expect.objectContaining({ cveEnrichment: expect.objectContaining({ cveId: 'CVE-2012-3037', description: 'Shared NVD description' }) })]
});
expect((await repository.loadAssessmentModel(assessment.id))?.cveEnrichments).toHaveLength(1);
expect((await repository.loadAssessmentModel(secondAssessment.id))?.cveEnrichments).toHaveLength(1);
});
});

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { databaseMigrations } from '../../server/postgres/migration-catalog';
describe('CVE enrichment schema', () => {
it('stores one global NVD record per normalized CVE without tenant ownership', () => {
const migration = databaseMigrations.find(item => item.version === '021');
expect(migration?.sql).toContain('CREATE TABLE assessor_cve_enrichments');
expect(migration?.sql).toContain('cve_id text PRIMARY KEY');
expect(migration?.sql).not.toMatch(/project_id|assessment_id|scan_run_id/);
expect(migration?.sql).toContain("source = 'nvd'");
});
});

View File

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { NvdCveClient, NvdCveClientError, parseNvdCveResponse } from '../../server/cve/nvd-cve-client';
const payload = {
version: '2.0',
vulnerabilities: [{ cve: {
id: 'CVE-2012-3037', sourceIdentifier: 'nvd@nist.gov', published: '2012-08-25T10:55:01.000',
lastModified: '2025-04-11T00:51:21.963', vulnStatus: 'Deferred',
descriptions: [{ lang: 'en', value: 'Synthetic vulnerability description.' }],
metrics: {
cvssMetricV31: [{ source: 'nvd@nist.gov', type: 'Primary', exploitabilityScore: 3.9, impactScore: 5.9,
cvssData: { version: '3.1', vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', baseScore: 9.8, baseSeverity: 'CRITICAL' } }]
},
weaknesses: [{ source: 'nvd@nist.gov', type: 'Primary', description: [{ lang: 'en', value: 'CWE-119' }] }],
references: [{ url: 'https://vendor.example/advisory', source: 'nvd@nist.gov', tags: ['Vendor Advisory', 'Patch'] }],
configurations: [{ operator: 'AND', nodes: [
{ cpeMatch: [{
vulnerable: true, criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', versionEndExcluding: '2.0'
}] },
{ cpeMatch: [{ vulnerable: false, criteria: 'cpe:2.3:h:vendor:device:*:*:*:*:*:*:*:*' }] }
] }],
cisaRequiredAction: 'Apply vendor guidance.'
} }]
};
describe('NVD CVE enrichment', () => {
it('normalizes the bounded NVD 2.0 response into shared CVE knowledge', () => {
const record = parseNvdCveResponse(payload, 'CVE-2012-3037', '2026-09-10T08:00:00.000Z');
expect(record).toMatchObject({
cveId: 'CVE-2012-3037', source: 'nvd', description: 'Synthetic vulnerability description.',
metrics: [{ version: '3.1', baseScore: 9.8, baseSeverity: 'CRITICAL' }],
weaknesses: [{ id: 'CWE-119' }],
references: [{ url: 'https://vendor.example/advisory', tags: ['Vendor Advisory', 'Patch'] }],
affectedConfigurations: [{
operator: 'AND',
vulnerableMatches: [{ criteria: 'cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*', versionEndExcluding: '2.0' }],
runningOnWith: [{ criteria: 'cpe:2.3:h:vendor:device:*:*:*:*:*:*:*:*' }]
}],
attributes: { cisaRequiredAction: 'Apply vendor guidance.' }
});
});
it('calls only the fixed NVD endpoint with an encoded exact CVE and optional API key', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify(payload), {
status: 200, headers: { 'Content-Type': 'application/json' }
}));
const client = new NvdCveClient({ fetcher, apiKey: 'test-key', now: () => new Date('2026-09-10T08:00:00.000Z') });
await expect(client.fetchCve('cve-2012-3037')).resolves.toMatchObject({ cveId: 'CVE-2012-3037' });
const url = fetcher.mock.calls[0][0] as URL;
expect(url.origin + url.pathname).toBe('https://services.nvd.nist.gov/rest/json/cves/2.0');
expect(url.searchParams.get('cveId')).toBe('CVE-2012-3037');
expect(new Headers(fetcher.mock.calls[0][1]?.headers).get('apiKey')).toBe('test-key');
});
it('rejects invalid identifiers before making a network request', async () => {
const fetcher = vi.fn<typeof fetch>();
const client = new NvdCveClient({ fetcher });
await expect(client.fetchCve('not-a-cve')).rejects.toMatchObject({ code: 'invalid_cve_id' } satisfies Partial<NvdCveClientError>);
expect(fetcher).not.toHaveBeenCalled();
});
});