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