Assessor_2.0/server/cve/nvd-cve-client.ts

214 lines
9.1 KiB
TypeScript

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