From 045d5b31cc2522a7d42f90eea5ed6517bfdfbd44 Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 11 Sep 2026 08:58:36 +0200 Subject: [PATCH] feat(cve-enrichment): implement CVE enrichment service and database schema --- SECURITY.md | 29 ++- docs/DEPLOYMENT.md | 11 + docs/IMPLEMENTATION_PLAN.md | 1 + docs/POSTGRESQL.md | 9 + server/assessor-http-server.ts | 23 ++ server/cve/cve-enrichment-service.ts | 22 ++ server/cve/nvd-cve-client.ts | 213 ++++++++++++++++++ server/index.ts | 7 + server/postgres/migration-catalog.ts | 4 +- .../migrations/021_cve_enrichment.sql | 16 ++ .../postgres/postgres-assessor-repository.ts | 57 ++++- src/api/assessor-api-client.ts | 13 +- src/api/assessor-api-contract.ts | 3 + src/domain/canonical-model.ts | 5 +- src/domain/cve-enrichment.ts | 100 ++++++++ src/domain/unified-result.ts | 6 +- src/features/finding-reader/FindingReader.tsx | 1 + .../canonical-model-to-dataset.ts | 79 ++++++- .../project-import/datahub-scan-analysis.ts | 2 + .../load-canonical-project-workspace.ts | 6 + .../results-browser/ResultsBrowser.tsx | 68 +++++- src/persistence/assessor-repository.ts | 5 + src/persistence/local-assessor-repository.ts | 40 +++- tests/api/assessor-api-client.test.ts | 15 ++ .../cve-enrichment-display-order.test.ts | 40 ++++ tests/features/finding-reader.test.ts | 29 +++ .../results-browser-cve-enrichment.test.ts | 25 ++ tests/local-only-check.mjs | 6 + tests/persistence/assessor-repository.test.ts | 48 ++++ tests/postgres/cve-enrichment-schema.test.ts | 12 + tests/server/nvd-cve-client.test.ts | 61 +++++ 31 files changed, 940 insertions(+), 16 deletions(-) create mode 100644 server/cve/cve-enrichment-service.ts create mode 100644 server/cve/nvd-cve-client.ts create mode 100644 server/postgres/migrations/021_cve_enrichment.sql create mode 100644 src/domain/cve-enrichment.ts create mode 100644 tests/features/cve-enrichment-display-order.test.ts create mode 100644 tests/features/results-browser-cve-enrichment.test.ts create mode 100644 tests/postgres/cve-enrichment-schema.test.ts create mode 100644 tests/server/nvd-cve-client.test.ts diff --git a/SECURITY.md b/SECURITY.md index 8d27bd5..a3f5aae 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,8 +39,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. @@ -137,6 +138,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. @@ -193,8 +213,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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 049644c..9eac382 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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= +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 diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 0000834..7a21ba9 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2580,6 +2580,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. | Datum | Änderung | +| 2026-09-10 | Separate globale CVE-Anreicherung nach dem Import umgesetzt: Das 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 passende Defender-Vorkommen. Same-Origin-API und Results-Browser bieten manuelle Enrich-/Refresh-Aktionen; der NVD-Client verwendet den festen CVE-2.0-Endpunkt mit Timeout und Antwortgrenze. Keine Scanner-Originalfelder werden veraendert. | | 2026-09-10 | Defender-Betroffenheitszahl von der Rolle `score` auf `asset` umgestellt: In ASSESSOR ist `score` die Risikomagnitude, 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`). | | 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl statt eines leeren `Affected Object`. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. | | 2026-09-10 | Defender-Ergebnisfenster auf eine Device-Tabelle umgestellt: `Affected Devices` zeigt Device, Scanner und Site; maschinenlesbare `Source Observations` bleiben als Evidenz erhalten und werden in der Lesespalte ausgeblendet. Keine SQL- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. | diff --git a/docs/POSTGRESQL.md b/docs/POSTGRESQL.md index cfcfeb3..ed1cf89 100644 --- a/docs/POSTGRESQL.md +++ b/docs/POSTGRESQL.md @@ -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 diff --git a/server/assessor-http-server.ts b/server/assessor-http-server.ts index bf5d46b..8690c3c 100644 --- a/server/assessor-http-server.ts +++ b/server/assessor-http-server.ts @@ -27,6 +27,8 @@ import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-ser import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service'; import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service'; import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-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 type { BackendArtifactStore } from './artifacts/backend-artifact-store'; @@ -136,6 +138,7 @@ export interface AssessorHttpServerOptions { gpoPolicyAnalysisOverviewService?: GpoPolicyAnalysisOverviewService; automaticGpoAnalysis?: GpoAutomaticAnalysisService; microsoftSecurityBaselineService?: MicrosoftSecurityBaselineService; + cveEnrichmentService?: CveEnrichmentService; } class HttpError extends Error { @@ -343,6 +346,10 @@ function statusForError(error: unknown): { status: number; code: string; 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 RiskAcceptanceValidationError) return { status: 400, code: 'risk_acceptance_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.' }; } @@ -912,6 +919,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>(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; } diff --git a/server/cve/cve-enrichment-service.ts b/server/cve/cve-enrichment-service.ts new file mode 100644 index 0000000..46dd623 --- /dev/null +++ b/server/cve/cve-enrichment-service.ts @@ -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) {} + + async enrich(value: string): Promise { + 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; + } +} diff --git a/server/cve/nvd-cve-client.ts b/server/cve/nvd-cve-client.ts new file mode 100644 index 0000000..afce7b3 --- /dev/null +++ b/server/cve/nvd-cve-client.ts @@ -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; + +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 { + 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()); + } +} diff --git a/server/index.ts b/server/index.ts index 05d0186..33727d5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,8 @@ 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 { loadPolicyEvaluationRuleCatalog } from './gpo/policy-evaluation-rule-catalog-loader'; +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; @@ -76,6 +78,10 @@ async function main(): Promise { const gpoPolicyAnalysisOverviewService = new GpoPolicyAnalysisOverviewService(runtime.repository); const automaticGpoAnalysis = new GpoAutomaticAnalysisService(runtime.repository, gpoPolicySnapshotService, gpoPolicyEvaluationService, gpoPolicyFindingService); + 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, @@ -114,6 +120,7 @@ async function main(): Promise { gpoPolicyFindingService, gpoPolicyAnalysisOverviewService, automaticGpoAnalysis, + cveEnrichmentService, importExecutor }); server.listen(parsedPort, host, () => { diff --git a/server/postgres/migration-catalog.ts b/server/postgres/migration-catalog.ts index 659b6ad..f65a094 100644 --- a/server/postgres/migration-catalog.ts +++ b/server/postgres/migration-catalog.ts @@ -18,6 +18,7 @@ import policyFindingFactory from './migrations/017_policy_finding_factory.sql?ra import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw'; import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw'; import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw'; +import cveEnrichment from './migrations/021_cve_enrichment.sql?raw'; export interface DatabaseMigration { version: string; @@ -45,5 +46,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ { version: '017', name: 'policy-finding-factory', sql: policyFindingFactory }, { version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }, { version: '019', name: 'native-policy-targets', sql: nativePolicyTargets }, - { version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation } + { version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation }, + { version: '021', name: 'cve-enrichment', sql: cveEnrichment } ]; diff --git a/server/postgres/migrations/021_cve_enrichment.sql b/server/postgres/migrations/021_cve_enrichment.sql new file mode 100644 index 0000000..074b5f7 --- /dev/null +++ b/server/postgres/migrations/021_cve_enrichment.sql @@ -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', '')) + ); diff --git a/server/postgres/postgres-assessor-repository.ts b/server/postgres/postgres-assessor-repository.ts index ff87c34..11712c7 100644 --- a/server/postgres/postgres-assessor-repository.ts +++ b/server/postgres/postgres-assessor-repository.ts @@ -25,6 +25,8 @@ import { POLICY_EVALUATION_ENGINE, storedPolicyEvaluationValidationErrors } from 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, @@ -362,6 +364,7 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record = { 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', @@ -535,6 +538,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>( + 'SELECT data FROM assessor_cve_enrichments WHERE cve_id = ANY($1::text[]) ORDER BY cve_id', [cveIds] + ) + : null; const evidence = await queryable.query>( `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] @@ -554,7 +567,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) }; } @@ -563,6 +577,38 @@ export class PostgresAssessorRepository implements AssessorRepository { return this.assessmentModel(this.pool, assessmentId); } + async getCveEnrichment(value: string): Promise { + const cveId = normalizeCveId(value); + if (!cveId) return null; + return dataRow(await this.pool.query>( + 'SELECT data FROM assessor_cve_enrichments WHERE cve_id = $1', [cveId] + )); + } + + async listCveEnrichments(values: string[]): Promise { + const cveIds = [...new Set(values.map(normalizeCveId).filter((value): value is string => Boolean(value)))]; + if (!cveIds.length) return []; + return dataRows(await this.pool.query>( + 'SELECT data FROM assessor_cve_enrichments WHERE cve_id = ANY($1::text[]) ORDER BY cve_id', [cveIds] + )); + } + + async saveCveEnrichment(record: CveEnrichmentRecord): Promise { + 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 { 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.`); @@ -958,6 +1004,7 @@ export class PostgresAssessorRepository implements AssessorRepository { definition: FindingDefinition; occurrence: FindingOccurrence; reviews: FindingReview[]; + cve_enrichment: CveEnrichmentRecord | null; } & QueryResultRow>(` SELECT p.data AS project, @@ -966,6 +1013,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 @@ -977,6 +1025,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); @@ -987,7 +1037,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) } : {}) })); } @@ -2351,6 +2402,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), diff --git a/src/api/assessor-api-client.ts b/src/api/assessor-api-client.ts index a49be86..89941d0 100644 --- a/src/api/assessor-api-client.ts +++ b/src/api/assessor-api-client.ts @@ -46,7 +46,8 @@ import { type MicrosoftSecurityBaselineDeletionResponse, type MicrosoftSecurityBaselineSelectionResponse, type MicrosoftSecurityBaselineAnalysisResponse, - type MicrosoftSecurityBaselineAnalysisListResponse + type MicrosoftSecurityBaselineAnalysisListResponse, + type CveEnrichmentResponse } from './assessor-api-contract'; export class AssessorApiError extends Error { @@ -191,6 +192,16 @@ export class AssessorApiClient { return this.request(`/results/${encodeURIComponent(resultProjectionId)}`); } + getCveEnrichment(cveId: string): Promise { + return this.request(`/cves/${encodeURIComponent(cveId)}`); + } + + enrichCve(cveId: string): Promise { + return this.request(`/cves/${encodeURIComponent(cveId)}/enrich`, { + method: 'POST', body: JSON.stringify({}) + }); + } + listResultCategories(query: UnifiedResultQuery = {}): Promise { return this.request(unifiedQueryPath('/results/categories', query)); } diff --git a/src/api/assessor-api-contract.ts b/src/api/assessor-api-contract.ts index 2e26160..448cf25 100644 --- a/src/api/assessor-api-contract.ts +++ b/src/api/assessor-api-contract.ts @@ -15,6 +15,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'; @@ -37,6 +38,8 @@ export interface AssessorApiErrorBody { }; } +export interface CveEnrichmentResponse { version: 1; record: CveEnrichmentRecord; } + export interface ImportSourceValidationRequest { fileName: string; sizeBytes: number; diff --git a/src/domain/canonical-model.ts b/src/domain/canonical-model.ts index ba99554..24c37fd 100644 --- a/src/domain/canonical-model.ts +++ b/src/domain/canonical-model.ts @@ -2,6 +2,7 @@ import type { ResultContractExposureState, ResultContractResultType } from './re import type { RiskAcceptance } from './risk-acceptance'; import type { EnvironmentObservation, ScannerReportSection } from './environment-context'; import type { SecurityScoringPolicy } from './security-scoring'; +import type { CveEnrichmentRecord } from './cve-enrichment'; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -347,9 +348,9 @@ 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[]; } - - diff --git a/src/domain/cve-enrichment.ts b/src/domain/cve-enrichment.ts new file mode 100644 index 0000000..ac20c26 --- /dev/null +++ b/src/domain/cve-enrichment.ts @@ -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; + /** Preserves small NVD additions that ASSESSOR does not yet normalize. */ + attributes: Record; +} + +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; +} diff --git a/src/domain/unified-result.ts b/src/domain/unified-result.ts index 65f5ea1..bde9848 100644 --- a/src/domain/unified-result.ts +++ b/src/domain/unified-result.ts @@ -39,6 +39,7 @@ import { discloseSupplementalObservation, type ResultDbSupplementalDisclosureClass } from './result-db-supplemental-disclosure'; +import type { CveEnrichmentRecord } from './cve-enrichment'; export type UnifiedResultSourceKind = 'scanner-finding' | 'policy-evaluation' | 'policy-observation' | 'environment-observation'; export type UnifiedResultAudience = 'result-db' | 'internal-analysis'; @@ -173,6 +174,7 @@ export interface UnifiedResultProjection { evaluatedAt?: string; createdAt: string; categories: UnifiedResultCategory[]; + cveEnrichment?: CveEnrichmentRecord; } export interface UnifiedResultQuery { @@ -244,6 +246,7 @@ export interface ScannerUnifiedResultSource { definition: FindingDefinition; occurrence: FindingOccurrence; reviews: FindingReview[]; + cveEnrichment?: CveEnrichmentRecord; } export interface PolicyUnifiedResultSource { @@ -413,7 +416,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 } : {}) }; } diff --git a/src/features/finding-reader/FindingReader.tsx b/src/features/finding-reader/FindingReader.tsx index 0224149..66a2962 100644 --- a/src/features/finding-reader/FindingReader.tsx +++ b/src/features/finding-reader/FindingReader.tsx @@ -243,6 +243,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(); diff --git a/src/features/project-import/canonical-model-to-dataset.ts b/src/features/project-import/canonical-model-to-dataset.ts index 8d5378d..fcdb59c 100644 --- a/src/features/project-import/canonical-model-to-dataset.ts +++ b/src/features/project-import/canonical-model-to-dataset.ts @@ -6,6 +6,8 @@ import { stableSheetId } from '../../shared/identifiers'; import { groupProjectImportFiles } from './datahub-import-groups'; import { canonicalSecurityScores } from '../../scoring/canonical-security-score'; import { defaultSecurityScoringPolicy } from '../../domain/security-scoring'; +import type { CveAffectedConfiguration, CveConfigurationMatch, CveEnrichmentRecord } from '../../domain/cve-enrichment'; +import { cveIdFromRuleId } from '../../domain/cve-enrichment'; function text(value: JsonValue): string { if (value === null) return ''; @@ -94,10 +96,80 @@ function withDerivedSeverity(fields: OriginalField[], occurrence: FindingOccurre return [...fields, { key: 'derived-severity', label: 'Severity', value: derived, path: 'derived/severity' }]; } -function displayFieldsByOccurrence(occurrences: FindingOccurrence[]): Map { +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 +): Map { 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) : []; + })() + ] ])); } @@ -262,7 +334,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[] = []; diff --git a/src/features/project-import/datahub-scan-analysis.ts b/src/features/project-import/datahub-scan-analysis.ts index e2496a7..4af9a8c 100644 --- a/src/features/project-import/datahub-scan-analysis.ts +++ b/src/features/project-import/datahub-scan-analysis.ts @@ -165,6 +165,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)) diff --git a/src/features/project-import/load-canonical-project-workspace.ts b/src/features/project-import/load-canonical-project-workspace.ts index 673eb76..4a971eb 100644 --- a/src/features/project-import/load-canonical-project-workspace.ts +++ b/src/features/project-import/load-canonical-project-workspace.ts @@ -40,6 +40,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, @@ -47,6 +52,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) diff --git a/src/features/results-browser/ResultsBrowser.tsx b/src/features/results-browser/ResultsBrowser.tsx index fc49cce..8124760 100644 --- a/src/features/results-browser/ResultsBrowser.tsx +++ b/src/features/results-browser/ResultsBrowser.tsx @@ -17,6 +17,19 @@ const PAGE_SIZE = 50; export const DEFAULT_RESULT_DB_FILTERS: UnifiedResultQuery = { deviation: true }; 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; @@ -152,6 +165,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen {queryResult.items.map(item => setExpandedId(current => current === item.resultProjectionId ? null : item.resultProjectionId)}/>)} } {queryResult && queryResult.total > queryResult.items.length &&
@@ -163,11 +177,33 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
; } -export function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultProjection; expanded: boolean; onToggle(): void }) { +export function ResultRow({ item, expanded, onToggle, onUpdated }: { + item: UnifiedResultProjection; + expanded: boolean; + onToggle(): void; + onUpdated(): Promise; +}) { const environment = item.sourceKind === 'environment-observation'; const policyObservation = item.sourceKind === 'policy-observation'; const policyComparison = item.sourceKind === 'policy-evaluation' && Boolean(item.policyBaselineLabel); const contextRecord = environment || policyObservation; + 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(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
+
+ {enrichmentError &&

{enrichmentError}

} + {item.cveEnrichment ? <> + {shouldShowNvdDescription(item.description, item.cveEnrichment.description) + &&

{item.cveEnrichment.description}

} +
    +
  • Status: {item.cveEnrichment.vulnerabilityStatus}
  • +
  • Published: {item.cveEnrichment.publishedAt} · Last modified: {item.cveEnrichment.lastModifiedAt}
  • +
  • Metrics: {item.cveEnrichment.metrics.map(metric => + `CVSS ${metric.version} ${metric.baseScore}${metric.baseSeverity ? ` ${metric.baseSeverity}` : ''} (${metric.source})` + ).join(' · ') || 'Not supplied'}
  • +
  • Weaknesses: {item.cveEnrichment.weaknesses.map(weakness => weakness.id).join(', ') || 'Not supplied'}
  • +
+ {item.cveEnrichment.references.length > 0 &&
+ Advisories, solutions, and tools ({item.cveEnrichment.references.length.toLocaleString('en-US')}) +
    {item.cveEnrichment.references.map(reference =>
  • + {reference.url} + {reference.tags.length ? ` · ${reference.tags.join(', ')}` : ''} +
  • )}
+
} +

Open the NVD record

+

NVD Affected Configurations: {item.cveEnrichment.affectedConfigurations.length.toLocaleString('en-US')}

+ :

No shared NVD enrichment has been stored yet.

} + } } ; } diff --git a/src/persistence/assessor-repository.ts b/src/persistence/assessor-repository.ts index 044f9fb..5fd76d6 100644 --- a/src/persistence/assessor-repository.ts +++ b/src/persistence/assessor-repository.ts @@ -33,6 +33,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; @@ -128,6 +129,7 @@ export interface ProjectDataExport { environmentObservations: EnvironmentObservation[]; findingDefinitions: FindingDefinition[]; findingOccurrences: FindingOccurrence[]; + cveEnrichments: CveEnrichmentRecord[]; evidence: EvidenceRecord[]; reviews: FindingReview[]; diagnostics: ImportDiagnostic[]; @@ -205,6 +207,9 @@ export interface AssessorRepository { getUnifiedResultById(resultProjectionId: string): Promise; listResultCategories(query?: UnifiedResultQuery): Promise; getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise; + getCveEnrichment(cveId: string): Promise; + listCveEnrichments(cveIds: string[]): Promise; + saveCveEnrichment(record: CveEnrichmentRecord): Promise; saveImportArtifact(artifact: ImportArtifact): Promise; saveImportArtifactAndEnqueueJob(artifact: ImportArtifact, job: IngestionJob): Promise; diff --git a/src/persistence/local-assessor-repository.ts b/src/persistence/local-assessor-repository.ts index bf9e1f9..310a242 100644 --- a/src/persistence/local-assessor-repository.ts +++ b/src/persistence/local-assessor-repository.ts @@ -25,6 +25,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, @@ -93,6 +95,7 @@ interface LocalRepositoryState { environmentObservations: EnvironmentObservation[]; findingDefinitions: FindingDefinition[]; findingOccurrences: FindingOccurrence[]; + cveEnrichments: CveEnrichmentRecord[]; evidence: EvidenceRecord[]; reviews: FindingReview[]; diagnostics: ImportDiagnostic[]; @@ -125,6 +128,7 @@ const emptyState = (): LocalRepositoryState => ({ environmentObservations: [], findingDefinitions: [], findingOccurrences: [], + cveEnrichments: [], evidence: [], reviews: [], diagnostics: [], @@ -186,6 +190,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; @@ -205,6 +213,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) @@ -335,6 +344,27 @@ export class LocalAssessorRepository implements AssessorRepository { return assessmentModel(this.state, assessmentId); } + async getCveEnrichment(value: string): Promise { + const cveId = normalizeCveId(value); + return cveId ? clone(this.state.cveEnrichments.find(record => record.cveId === cveId) ?? null) : null; + } + + async listCveEnrichments(values: string[]): Promise { + 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 { + 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 { 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.`); @@ -567,7 +597,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 + )) })]; }); } @@ -1380,6 +1413,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, @@ -1391,6 +1428,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))), diff --git a/tests/api/assessor-api-client.test.ts b/tests/api/assessor-api-client.test.ts index 2578b1d..c853afb 100644 --- a/tests/api/assessor-api-client.test.ts +++ b/tests/api/assessor-api-client.test.ts @@ -301,4 +301,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().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'); + }); }); diff --git a/tests/features/cve-enrichment-display-order.test.ts b/tests/features/cve-enrichment-display-order.test.ts new file mode 100644 index 0000000..1d6b1ee --- /dev/null +++ b/tests/features/cve-enrichment-display-order.test.ts @@ -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); + }); +}); diff --git a/tests/features/finding-reader.test.ts b/tests/features/finding-reader.test.ts index 8c4c1f8..9bfa266 100644 --- a/tests/features/finding-reader.test.ts +++ b/tests/features/finding-reader.test.ts @@ -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, diff --git a/tests/features/results-browser-cve-enrichment.test.ts b/tests/features/results-browser-cve-enrichment.test.ts new file mode 100644 index 0000000..509dc22 --- /dev/null +++ b/tests/features/results-browser-cve-enrichment.test.ts @@ -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); + }); +}); diff --git a/tests/local-only-check.mjs b/tests/local-only-check.mjs index f42b5eb..6b1fe66 100644 --- a/tests/local-only-check.mjs +++ b/tests/local-only-check.mjs @@ -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'); diff --git a/tests/persistence/assessor-repository.test.ts b/tests/persistence/assessor-repository.test.ts index ff73491..52db38e 100644 --- a/tests/persistence/assessor-repository.test.ts +++ b/tests/persistence/assessor-repository.test.ts @@ -399,4 +399,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); + }); }); diff --git a/tests/postgres/cve-enrichment-schema.test.ts b/tests/postgres/cve-enrichment-schema.test.ts new file mode 100644 index 0000000..dc384b8 --- /dev/null +++ b/tests/postgres/cve-enrichment-schema.test.ts @@ -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'"); + }); +}); diff --git a/tests/server/nvd-cve-client.test.ts b/tests/server/nvd-cve-client.test.ts new file mode 100644 index 0000000..9a6461d --- /dev/null +++ b/tests/server/nvd-cve-client.test.ts @@ -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().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(); + const client = new NvdCveClient({ fetcher }); + await expect(client.fetchCve('not-a-cve')).rejects.toMatchObject({ code: 'invalid_cve_id' } satisfies Partial); + expect(fetcher).not.toHaveBeenCalled(); + }); +});