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 57e57d1abb
commit 045d5b31cc
31 changed files with 940 additions and 16 deletions

View File

@ -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

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

@ -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. |

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

@ -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<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

@ -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<void> {
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<void> {
gpoPolicyFindingService,
gpoPolicyAnalysisOverviewService,
automaticGpoAnalysis,
cveEnrichmentService,
importExecutor
});
server.listen(parsedPort, host, () => {

View File

@ -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 }
];

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

@ -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<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',
@ -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<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]
@ -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<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.`);
@ -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),

View File

@ -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<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

@ -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;

View File

@ -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[];
}

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

@ -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 } : {})
};
}

View File

@ -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<string>();

View File

@ -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<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) : [];
})()
]
]));
}
@ -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[] = [];

View File

@ -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))

View File

@ -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)

View File

@ -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
</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">
@ -163,11 +177,33 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
</div>;
}
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<void>;
}) {
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<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>
@ -234,6 +270,36 @@ export function ResultRow({ item, expanded, onToggle }: { item: UnifiedResultPro
</>}
<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

@ -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<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

@ -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<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.`);
@ -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))),

View File

@ -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<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

@ -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);
});
});

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();
});
});