From ff9d4cbfdbfa060763508e6d256bf8270cfb04eb Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Thu, 12 Mar 2026 15:04:46 +0100 Subject: [PATCH] refactor(database/NVD)!: implement all parameters and minimal timeout --- docs/database/nvd.md | 108 ++++++++- src/database/nvd.ts | 247 +++++++++++++++++++-- test/database/nvd.unit.spec.ts | 393 ++++++++++++++++++++++++++++++++- 3 files changed, 704 insertions(+), 44 deletions(-) diff --git a/docs/database/nvd.md b/docs/database/nvd.md index 339af72..285ef87 100644 --- a/docs/database/nvd.md +++ b/docs/database/nvd.md @@ -1,6 +1,6 @@ # NVD -NVD stand for National Vulnerability Database, which is the U.S. government repository of standards-based vulnerability management data. This database is maintained by NIST (National Institute of Standards and Technology). +NVD stands for National Vulnerability Database, which is the U.S. government repository of standards-based vulnerability management data. This database is maintained by NIST (National Institute of Standards and Technology). ## Implementation Notes @@ -8,7 +8,7 @@ The NVD integration uses the REST API (v2.0) available at [services.nvd.nist.gov ### Search Parameters -While the NVD API supports CPE matching via the `cpeMatchString` parameter, we've chosen to use only keyword search for NPM packages. This decision was made because: +While the NVD API supports CPE matching via parameters like `cpeName` and `virtualMatchString`, we've chosen not to use them for NPM packages. This decision was made because: 1. The CPE format for npm packages is not standardized in NVD 2. Attempted CPE patterns (like `cpe:2.3:a:*:package-name:*:*:*:*:*:node.js:*:*`) resulted in 404 errors @@ -16,6 +16,16 @@ While the NVD API supports CPE matching via the `cpeMatchString` parameter, we'v The implementation might be enhanced in the future if NVD provides clearer guidelines for CPE matching of npm packages. +### Parameter Constraints + +Some parameters have mutual exclusivity constraints enforced by the NVD API: + +- `cvssV2Severity`, `cvssV3Severity`, and `cvssV4Severity` cannot be combined with each other. +- `cvssV2Metrics`, `cvssV3Metrics`, and `cvssV4Metrics` cannot be combined with each other. +- `keywordExactMatch` requires `keywordSearch` to be set. +- `pubStartDate` and `pubEndDate` must be used together; the maximum range is 120 days. +- `lastModStartDate` and `lastModEndDate` must be used together; the maximum range is 120 days. + ## Format The NVD API returns detailed vulnerability information. @@ -46,35 +56,109 @@ const db = new vulnera.Database.NVD({ ```ts export interface NVDOptions { - credential?: ApiCredential; + credential: ApiCredential; + /** + * Delay in milliseconds between consecutive requests in findMany. + * + * The NVD API enforces rate limits: + * - Without API key: 5 requests per 30-second window → set ~6 000 ms + * - With API key: 50 requests per 30-second window → set ~600 ms + * + * @default 6000 + */ + requestDelay?: number; } ``` -### `findOne(parameters: NVDApiParameter): Promise` -Find the vulnerabilities of a given package using available NVD API parameters. +> **Rate limiting:** The NVD API enforces strict rate limits (see [NVD developer docs](https://nvd.nist.gov/developers/start-here)). `findMany` sends requests sequentially with a `requestDelay` pause between each one to avoid being throttled. The default of 6 000 ms is safe for unauthenticated use. If you supply an API key you can safely lower it to ~600 ms. + +### `find(parameters: NVDApiParameter): Promise` +Find vulnerabilities using any combination of available NVD API parameters. ```ts export type NVDApiParameter = { + // Keyword search keywordSearch?: string; + keywordExactMatch?: boolean; + + // Convenience fields (used by findBySpec / findMany) + packageName?: string; + ecosystem?: string; // default: "npm" + + // CVE identification + cveId?: string; + cveTag?: "disputed" | "unsupported-when-assigned" | "exclusively-hosted-service"; cweId?: string; + sourceIdentifier?: string; + + // CVSS severity (mutually exclusive across versions) + cvssV2Severity?: "LOW" | "MEDIUM" | "HIGH"; cvssV3Severity?: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; - packageName?: string; - ecosystem?: string; + cvssV4Severity?: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; + + // CVSS vector strings (mutually exclusive across versions) + cvssV2Metrics?: string; + cvssV3Metrics?: string; + cvssV4Metrics?: string; + + // Boolean flags + noRejected?: boolean; + hasKev?: boolean; + hasCertAlerts?: boolean; + hasCertNotes?: boolean; + hasOval?: boolean; + + // Date ranges (ISO-8601, max 120-day window per pair) + pubStartDate?: string; + pubEndDate?: string; + lastModStartDate?: string; + lastModEndDate?: string; + + // Pagination + resultsPerPage?: number; // default and max: 2000 + startIndex?: number; // default: 0 }; ``` -### `findOneBySpec(spec: string): Promise` -Find the vulnerabilities of a given package using the NPM spec format like `packageName@version`. +**Examples:** + +```ts +// Filter by CVSSv3 severity +const vulns = await db.find({ keywordSearch: "express", cvssV3Severity: "CRITICAL" }); + +// Return only CVEs in the CISA Known Exploited Vulnerabilities catalog +const kevVulns = await db.find({ keywordSearch: "log4j", hasKev: true }); + +// Paginate results +const page2 = await db.find({ keywordSearch: "lodash", resultsPerPage: 100, startIndex: 100 }); + +// Filter by publication date range +const recent = await db.find({ + pubStartDate: "2024-01-01T00:00:00.000Z", + pubEndDate: "2024-04-30T23:59:59.000Z" +}); +``` + +### `findByCveId(cveId: string): Promise` +Find a specific vulnerability by its CVE identifier. + +```ts +const vuln = await db.findByCveId("CVE-2021-44228"); +console.log(vuln); +``` + +### `findBySpec(spec: string): Promise` +Find vulnerabilities of a given package using the NPM spec format `packageName@version`. ```ts -const vulns = await db.findOneBySpec("express@4.0.0"); +const vulns = await db.findBySpec("express@4.0.0"); console.log(vulns); ``` ### `findMany(specs: T[]): Promise>` -Find the vulnerabilities of many packages using the spec format. +Find vulnerabilities for many packages using the spec format. -Returns a Record where keys are equals to the provided specs. +Returns a Record where keys are equal to the provided specs. ```ts const vulns = await db.findMany(["express@4.0.0", "lodash@4.17.0"]); diff --git a/src/database/nvd.ts b/src/database/nvd.ts index 5dc317f..3339d56 100644 --- a/src/database/nvd.ts +++ b/src/database/nvd.ts @@ -1,3 +1,6 @@ +// Import Node.js Dependencies +import timers from "node:timers/promises"; + // Import Third-party Dependencies import * as httpie from "@openally/httpie"; @@ -9,40 +12,166 @@ import type { ApiCredential } from "../credential.ts"; /** * @description Parameters for querying the NVD API * - * Note: While NVD API supports cpeMatchString for CPE-based matching, - * we don't use it for npm packages due to compatibility issues with + * Note: While NVD API supports CPE-based matching (cpeName, virtualMatchString, etc.), + * we don't use those parameters for npm packages due to compatibility issues with * the CPE format. Instead, we rely on keywordSearch which provides * more reliable results for npm packages. * * See docs/database/nvd.md for more details on this implementation choice. + * + * @see https://nvd.nist.gov/developers/vulnerabilities */ export type NVDApiParameter = { + /** + * Searches CVE descriptions by keyword or phrase. + * Spaces should be encoded as %20; wildcard matching is implicit. + */ keywordSearch?: string; - cweId?: string; - cvssV3Severity?: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; + /** + * When true, enforces exact phrase matching for multi-term searches. + * Requires keywordSearch. + */ + keywordExactMatch?: boolean; + /** + * Convenience parameter that maps to keywordSearch using the package name. + * Used internally by findOneBySpec. + * @default npm + */ packageName?: string; /** * @default npm */ ecosystem?: string; + /** + * Returns a specific CVE by its CVE identifier (e.g. "CVE-2021-44228"). + */ + cveId?: string; + /** + * Filters CVE by tag type. + */ + cveTag?: "disputed" | "unsupported-when-assigned" | "exclusively-hosted-service"; + /** + * Filters by CWE identifier (e.g. "CWE-79") or placeholders + * "NVD-CWE-Other" / "NVD-CWE-noinfo". + */ + cweId?: string; + /** + * Filters by CVSSv2 severity level. + * Cannot be combined with cvssV3Severity or cvssV4Severity. + */ + cvssV2Severity?: "LOW" | "MEDIUM" | "HIGH"; + /** + * Filters by CVSSv3 severity level. + * Cannot be combined with cvssV2Severity or cvssV4Severity. + */ + cvssV3Severity?: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; + /** + * Filters by CVSSv4 severity level. + * Cannot be combined with cvssV2Severity or cvssV3Severity. + */ + cvssV4Severity?: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; + /** + * Filters by CVSSv2 vector string (full or partial). + * Cannot be combined with cvssV3Metrics or cvssV4Metrics. + */ + cvssV2Metrics?: string; + /** + * Filters by CVSSv3 vector string (full or partial). + * Cannot be combined with cvssV2Metrics or cvssV4Metrics. + */ + cvssV3Metrics?: string; + /** + * Filters by CVSSv4 vector string (full or partial). + * Cannot be combined with cvssV2Metrics or cvssV3Metrics. + */ + cvssV4Metrics?: string; + /** + * When true, excludes CVE with REJECT or Rejected status. + */ + noRejected?: boolean; + /** + * When true, returns only CVE appearing in the CISA Known Exploited + * Vulnerabilities (KEV) catalog. + */ + hasKev?: boolean; + /** + * When true, returns only CVE with US-CERT Technical Alerts. + */ + hasCertAlerts?: boolean; + /** + * When true, returns only CVE with CERT/CC Vulnerability Notes. + */ + hasCertNotes?: boolean; + /** + * When true, returns only CVE containing MITRE OVAL information. + */ + hasOval?: boolean; + /** + * Start date for the publication date filter (ISO-8601, e.g. "2021-01-01T00:00:00.000Z"). + * Must be paired with pubEndDate. Maximum range is 120 days. + */ + pubStartDate?: string; + /** + * End date for the publication date filter (ISO-8601). + * Must be paired with pubStartDate. Maximum range is 120 days. + */ + pubEndDate?: string; + /** + * Start date for the last-modified filter (ISO-8601). + * Must be paired with lastModEndDate. Maximum range is 120 days. + */ + lastModStartDate?: string; + /** + * End date for the last-modified filter (ISO-8601). + * Must be paired with lastModStartDate. Maximum range is 120 days. + */ + lastModEndDate?: string; + /** + * Filters by the exact data source identifier. + */ + sourceIdentifier?: string; + /** + * Maximum number of CVE records returned per response. + * @default 2000 + * @maximum 2000 + */ + resultsPerPage?: number; + /** + * Zero-based index of the first CVE record to return (for pagination). + * @default 0 + */ + startIndex?: number; }; export interface NVDOptions { credential: ApiCredential; + /** + * Delay in milliseconds between consecutive requests in findMany. + * + * The NVD API enforces rate limits: + * - Without API key: 5 requests per 30-second window (~6 000 ms between requests) + * - With API key: 50 requests per 30-second window (~600 ms between requests) + * + * @default 6000 + * @see https://nvd.nist.gov/developers/start-here + */ + requestDelay?: number; } export class NVD { static readonly ROOT_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"; readonly #credential: ApiCredential; + readonly #requestDelay: number; constructor( options: NVDOptions ) { this.#credential = options.credential; + this.#requestDelay = options.requestDelay ?? 6_000; } - async findOne( + async find( parameters: NVDApiParameter ): Promise { const queryParams = new URLSearchParams(); @@ -50,16 +179,81 @@ export class NVD { if (parameters.packageName) { queryParams.append("keywordSearch", parameters.packageName); /** - * NVD doesn't support cpeMatchString - * We will only search by keyword for now + * NVD doesn't support cpeMatchString for npm packages. + * We only search by keyword. */ } - if (parameters.cvssV3Severity) { - queryParams.append("cvssV3Severity", parameters.cvssV3Severity); + else if (parameters.keywordSearch) { + queryParams.append("keywordSearch", parameters.keywordSearch); + } + + if (parameters.keywordExactMatch) { + queryParams.append("keywordExactMatch", ""); + } + if (parameters.cveId) { + queryParams.append("cveId", parameters.cveId); + } + if (parameters.cveTag) { + queryParams.append("cveTag", parameters.cveTag); } if (parameters.cweId) { queryParams.append("cweId", parameters.cweId); } + if (parameters.cvssV2Severity) { + queryParams.append("cvssV2Severity", parameters.cvssV2Severity); + } + if (parameters.cvssV3Severity) { + queryParams.append("cvssV3Severity", parameters.cvssV3Severity); + } + if (parameters.cvssV4Severity) { + queryParams.append("cvssV4Severity", parameters.cvssV4Severity); + } + if (parameters.cvssV2Metrics) { + queryParams.append("cvssV2Metrics", parameters.cvssV2Metrics); + } + if (parameters.cvssV3Metrics) { + queryParams.append("cvssV3Metrics", parameters.cvssV3Metrics); + } + if (parameters.cvssV4Metrics) { + queryParams.append("cvssV4Metrics", parameters.cvssV4Metrics); + } + if (parameters.noRejected) { + queryParams.append("noRejected", ""); + } + if (parameters.hasKev) { + queryParams.append("hasKev", ""); + } + if (parameters.hasCertAlerts) { + queryParams.append("hasCertAlerts", ""); + } + if (parameters.hasCertNotes) { + queryParams.append("hasCertNotes", ""); + } + if (parameters.hasOval) { + queryParams.append("hasOval", ""); + } + if (parameters.pubStartDate) { + queryParams.append("pubStartDate", parameters.pubStartDate); + } + if (parameters.pubEndDate) { + queryParams.append("pubEndDate", parameters.pubEndDate); + } + if (parameters.lastModStartDate) { + queryParams.append("lastModStartDate", parameters.lastModStartDate); + } + if (parameters.lastModEndDate) { + queryParams.append("lastModEndDate", parameters.lastModEndDate); + } + if (parameters.sourceIdentifier) { + queryParams.append("sourceIdentifier", parameters.sourceIdentifier); + } + if (parameters.resultsPerPage !== undefined) { + queryParams.append("resultsPerPage", String(parameters.resultsPerPage)); + } + if (parameters.startIndex !== undefined) { + queryParams.append("startIndex", String(parameters.startIndex)); + } + for (const [name, value] of Object.entries(this.#credential.queryParams)) { queryParams.append(name, value); } @@ -67,24 +261,25 @@ export class NVD { const url = new URL(NVD.ROOT_API); url.search = queryParams.toString(); - try { - const { data } = await httpie.get<{ vulnerabilities: NVDFormat[]; }>(url.toString()); + const { data } = await httpie.get<{ vulnerabilities: NVDFormat[]; }>( + url.toString() + ); - return data.vulnerabilities || []; - } - catch (error: any) { - console.error("NVD API Error:", error.message || error); + return data.vulnerabilities || []; + } - return []; - } + findByCveId( + cveId: string + ): Promise { + return this.find({ cveId }); } - findOneBySpec( + findBySpec( spec: string ): Promise { const { name } = utils.parseNpmSpec(spec); - return this.findOne({ + return this.find({ packageName: name, ecosystem: "npm" }); @@ -93,10 +288,16 @@ export class NVD { async findMany( specs: T[] ): Promise> { - const entries = await Promise.all( - specs.map(async(spec) => [spec, await this.findOneBySpec(spec)] as [T, NVDFormat[]]) - ); + const result = {} as Record; + + for (let i = 0; i < specs.length; i++) { + result[specs[i]] = await this.findBySpec(specs[i]); + + if (i < specs.length - 1) { + await timers.setTimeout(this.#requestDelay); + } + } - return Object.fromEntries(entries) as Record; + return result; } } diff --git a/test/database/nvd.unit.spec.ts b/test/database/nvd.unit.spec.ts index 6d937a3..a59e57a 100644 --- a/test/database/nvd.unit.spec.ts +++ b/test/database/nvd.unit.spec.ts @@ -15,7 +15,7 @@ const kTestCredential = new ApiCredential({ type: "querystring", name: "apiKey", const kNvdPathname = new URL(NVD.ROOT_API).pathname; describe("Database.NVD", () => { - const db = new NVD({ credential: kTestCredential }); + const db = new NVD({ credential: kTestCredential, requestDelay: 0 }); const [mockedHttpAgent, restoreHttpAgent] = setupHttpAgentMock(); const mockedHttpClient = mockedHttpAgent.get(new URL(NVD.ROOT_API).origin); @@ -23,7 +23,7 @@ describe("Database.NVD", () => { restoreHttpAgent(); }); - test(`should send a GET http request to the NVD API using findOne + test(`should send a GET http request to the NVD API using find and then return the 'vulnerabilities' property from the JSON response`, async() => { const expectedResponse = { vulnerabilities: ["cve-data-1", "cve-data-2"] }; const params = new URLSearchParams(); @@ -38,7 +38,7 @@ describe("Database.NVD", () => { }) .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); - const vulns = await db.findOne({ + const vulns = await db.find({ packageName: "express", ecosystem: "npm" }); @@ -46,7 +46,49 @@ describe("Database.NVD", () => { assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); }); - test("should send a GET http request with severity parameter", async() => { + test("should send a GET http request with keywordSearch directly", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "log4j"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ keywordSearch: "log4j" }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with keywordExactMatch flag", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "express framework"); + params.append("keywordExactMatch", ""); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ + keywordSearch: "express framework", + keywordExactMatch: true + }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with cvssV3Severity parameter", async() => { const expectedResponse = { vulnerabilities: ["cve-data-1"] }; const params = new URLSearchParams(); params.append("keywordSearch", "express"); @@ -61,7 +103,7 @@ describe("Database.NVD", () => { }) .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); - const vulns = await db.findOne({ + const vulns = await db.find({ packageName: "express", ecosystem: "npm", cvssV3Severity: "HIGH" @@ -70,7 +112,318 @@ describe("Database.NVD", () => { assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); }); - test("should send a GET http request to the NVD API using findOneBySpec", async() => { + test("should send a GET http request with cvssV2Severity parameter", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "express"); + params.append("cvssV2Severity", "MEDIUM"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ + packageName: "express", + cvssV2Severity: "MEDIUM" + }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with cvssV4Severity parameter", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "express"); + params.append("cvssV4Severity", "CRITICAL"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ + packageName: "express", + cvssV4Severity: "CRITICAL" + }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with cvssV3Metrics vector string", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"; + const params = new URLSearchParams(); + params.append("cvssV3Metrics", vector); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ cvssV3Metrics: vector }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with cveId parameter", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("cveId", "CVE-2021-44228"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ cveId: "CVE-2021-44228" }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request using findByCveId", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("cveId", "CVE-2021-44228"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.findByCveId("CVE-2021-44228"); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with cveTag parameter", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "express"); + params.append("cveTag", "disputed"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ packageName: "express", cveTag: "disputed" }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with noRejected flag", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "express"); + params.append("noRejected", ""); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ packageName: "express", noRejected: true }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with hasKev flag", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "log4j"); + params.append("hasKev", ""); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ keywordSearch: "log4j", hasKev: true }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with hasCertAlerts flag", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("hasCertAlerts", ""); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ hasCertAlerts: true }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with hasCertNotes flag", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("hasCertNotes", ""); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ hasCertNotes: true }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with hasOval flag", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("hasOval", ""); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ hasOval: true }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with publication date range", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("pubStartDate", "2024-01-01T00:00:00.000Z"); + params.append("pubEndDate", "2024-04-30T23:59:59.000Z"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ + pubStartDate: "2024-01-01T00:00:00.000Z", + pubEndDate: "2024-04-30T23:59:59.000Z" + }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with last-modified date range", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("lastModStartDate", "2024-01-01T00:00:00.000Z"); + params.append("lastModEndDate", "2024-04-30T23:59:59.000Z"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ + lastModStartDate: "2024-01-01T00:00:00.000Z", + lastModEndDate: "2024-04-30T23:59:59.000Z" + }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with sourceIdentifier parameter", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("sourceIdentifier", "cve@mitre.org"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ sourceIdentifier: "cve@mitre.org" }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request with pagination parameters", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const params = new URLSearchParams(); + params.append("keywordSearch", "express"); + params.append("resultsPerPage", "100"); + params.append("startIndex", "200"); + params.append("apiKey", kTestApiKey); + const queryString = params.toString(); + + mockedHttpClient + .intercept({ + path: `${kNvdPathname}?${queryString}`, + method: "GET" + }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + + const vulns = await db.find({ + packageName: "express", + resultsPerPage: 100, + startIndex: 200 + }); + + assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); + }); + + test("should send a GET http request to the NVD API using findBySpec", async() => { const expectedResponse = { vulnerabilities: ["cve-data-1", "cve-data-2"] }; const packageName = "express"; const params = new URLSearchParams(); @@ -85,11 +438,11 @@ describe("Database.NVD", () => { }) .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); - const vulns = await db.findOneBySpec(`${packageName}@1.0.0`); + const vulns = await db.findBySpec(`${packageName}@1.0.0`); assert.deepStrictEqual(vulns, expectedResponse.vulnerabilities); }); - test("should send multiple GET http requests to the NVD API using findMany", async() => { + test("should send sequential GET http requests to the NVD API using findMany", async() => { const expectedResponse = { vulnerabilities: ["cve-data-1", "cve-data-2"] }; const paramsFirst = new URLSearchParams(); @@ -126,6 +479,28 @@ describe("Database.NVD", () => { }); }); + test("findMany should respect requestDelay between requests", async() => { + const expectedResponse = { vulnerabilities: ["cve-data-1"] }; + const delayMs = 50; + const dbWithDelay = new NVD({ credential: kTestCredential, requestDelay: delayMs }); + + for (const pkg of ["pkg-a", "pkg-b", "pkg-c"]) { + const params = new URLSearchParams(); + params.append("keywordSearch", pkg); + params.append("apiKey", kTestApiKey); + mockedHttpClient + .intercept({ path: `${kNvdPathname}?${params.toString()}`, method: "GET" }) + .reply(200, expectedResponse, HTTP_CLIENT_HEADERS); + } + + const start = Date.now(); + await dbWithDelay.findMany(["pkg-a", "pkg-b", "pkg-c"]); + const elapsed = Date.now() - start; + + // 3 packages → 2 delays of delayMs each (no delay after the last request) + assert.ok(elapsed >= delayMs * 2, `Expected at least ${delayMs * 2}ms, got ${elapsed}ms`); + }); + test("should handle empty response from NVD API", async() => { const emptyResponse = {}; @@ -141,7 +516,7 @@ describe("Database.NVD", () => { }) .reply(200, emptyResponse, HTTP_CLIENT_HEADERS); - const vulns = await db.findOne({ + const vulns = await db.find({ packageName: "nonexistent", ecosystem: "npm" });