216 lines
9.3 KiB
TypeScript
216 lines
9.3 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 [];
|
|
// CVSS v3.x/v4.0 carry baseSeverity inside cvssData; CVSS v2 carries it on the metric entry itself.
|
|
const baseSeverity = string(data.baseSeverity) || string(item.baseSeverity);
|
|
return [{
|
|
version,
|
|
source: string(item.source) || 'unknown',
|
|
...(string(item.type) ? { type: string(item.type) } : {}),
|
|
baseScore,
|
|
...(baseSeverity ? { 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());
|
|
}
|
|
}
|