@cliwant/mcp-sam-gov 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +226 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3889 -296
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/dist/nvd.js
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cliwant/mcp-sam-gov/nvd — the IT / CYBER-COMPLIANCE lane (keyless).
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (ADR-0035)
|
|
5
|
+
* --------------------------
|
|
6
|
+
* The server has DEEP regulatory-Comply (FAR/eCFR/SBA) and DEEP entity vetting
|
|
7
|
+
* (EDGAR/FDIC/OFAC) but ZERO IT/cyber compliance — a whole GovCon segment
|
|
8
|
+
* (FedRAMP-authorized cloud, CMMC-obligated defense IT, SBOM/supply-chain) is
|
|
9
|
+
* unserved. This ONE keyless source opens that lane by JOINing two federal
|
|
10
|
+
* upstreams:
|
|
11
|
+
* - NIST NVD CVE API 2.0 (services.nvd.nist.gov) — the authoritative federal
|
|
12
|
+
* CVE/CVSS database.
|
|
13
|
+
* - CISA KEV catalog (www.cisa.gov) — the Known-Exploited-Vulnerabilities list
|
|
14
|
+
* carrying BINDING remediation due-dates under BOD 22-01 / its 2026 successor
|
|
15
|
+
* BOD 26-04.
|
|
16
|
+
* The B2G unlock is the JOIN: a GovCon IT vendor checking a product/component
|
|
17
|
+
* gets both "how severe is this CVE (CVSS)" AND "does CISA mandate remediation
|
|
18
|
+
* by a date" in one `cve_lookup` row.
|
|
19
|
+
*
|
|
20
|
+
* TWO tools on THIS one source:
|
|
21
|
+
* - cve_lookup — NVD CVE detail/search JOINED with KEV status.
|
|
22
|
+
* - cisa_kev_lookup — the KEV catalog filtered standalone.
|
|
23
|
+
*
|
|
24
|
+
* ★ NEVER-FAKE honesty (mirrors OFAC's never-fake-CLEAR doctrine):
|
|
25
|
+
* - P1 pagination derives from `totalResults` (the EXACT count), NEVER page
|
|
26
|
+
* length.
|
|
27
|
+
* - P2 a genuine `totalResults:0` / `found:false` is honest; ANY 404 / 5xx /
|
|
28
|
+
* timeout / network / off-host-redirect / 403 / 429 THROWS (never fake-empty).
|
|
29
|
+
* - P3 base scores / counts are null-never-0 (`num`); an absent/Rejected score
|
|
30
|
+
* is null, a real 0.0 CVSS is 0.
|
|
31
|
+
* - A KEV download failure NEVER fabricates "not on the mandatory-remediation
|
|
32
|
+
* list": cve_lookup degrades to `kev.listed:null` (never false); cisa_kev_lookup
|
|
33
|
+
* THROWS; and a `kevOnly:true` filter during a KEV outage THROWS (M1).
|
|
34
|
+
*
|
|
35
|
+
* The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
|
|
36
|
+
* (redirect:"error"), `throughGate`, `driftError`, `num`/`str` (null-never-0),
|
|
37
|
+
* `withMeta`/`buildMeta`, and MIRRORS the OFAC catalog-cache + the BLS
|
|
38
|
+
* optional-key/tier-disclosure seam.
|
|
39
|
+
*
|
|
40
|
+
* ★ SSRF (policy①): both hosts are compile-time CONSTANTS; NO caller value
|
|
41
|
+
* touches host/path. NVD caller inputs are QUERY params only (validated then
|
|
42
|
+
* `URLSearchParams`-encoded); KEV takes no params (filters run client-side over
|
|
43
|
+
* the cached catalog). A post-construction hostname/protocol assert +
|
|
44
|
+
* `redirect:"error"` lock both (fail closed on any off-host 3xx; its body never
|
|
45
|
+
* read).
|
|
46
|
+
*
|
|
47
|
+
* ★ OPTIONAL NVD_API_KEY (never leaked): read once via `resolvedNvdKey()` (the
|
|
48
|
+
* ONLY reader; mirrors `resolvedBlsKey`). When set, injected ONLY into the
|
|
49
|
+
* documented `apiKey` HTTP header and NOWHERE else (never the URL, label,
|
|
50
|
+
* `_meta`, or a log), and it shortens the self-throttle interval. Only the MODE
|
|
51
|
+
* is ever disclosed.
|
|
52
|
+
*/
|
|
53
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
54
|
+
import { getJson, driftError, throughGate } from "./datasource.js";
|
|
55
|
+
import { num, str } from "./coerce.js";
|
|
56
|
+
import { withMeta } from "./meta.js";
|
|
57
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
58
|
+
// ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
|
|
59
|
+
// `num` (nvd.num === coerce.num — a num regression fails together across sources).
|
|
60
|
+
export { num };
|
|
61
|
+
// ─── NVD endpoint (SSRF core — compile-time CONSTANTS) ────────────
|
|
62
|
+
const NVD_HOST = "services.nvd.nist.gov";
|
|
63
|
+
const NVD_PATH = "/rest/json/cves/2.0";
|
|
64
|
+
const NVD_CVES_URL = `https://${NVD_HOST}${NVD_PATH}`;
|
|
65
|
+
// HOST+path-only label (surfaces in ToolError.upstreamEndpoint; the API key rides
|
|
66
|
+
// in the apiKey header, never here — leak-safe).
|
|
67
|
+
const NVD_LABEL = "nvd:/rest/json/cves/2.0";
|
|
68
|
+
// SEC-style UA — NVD serves the keyless API to a normal client.
|
|
69
|
+
const NVD_UA = "Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
|
|
70
|
+
const NVD_GATE_KEY = "nvd";
|
|
71
|
+
// Keyless ~5 req/30s ≈ 1 req/6s; with a free NVD_API_KEY ~50 req/30s ≈ 1 req/0.6s.
|
|
72
|
+
const NVD_MIN_INTERVAL_KEYLESS_MS = 6_000;
|
|
73
|
+
const NVD_MIN_INTERVAL_KEYED_MS = 700;
|
|
74
|
+
const NVD_TIMEOUT_MS = 30_000;
|
|
75
|
+
// ─── KEV endpoint (SSRF core — compile-time CONSTANTS) ────────────
|
|
76
|
+
const KEV_HOST = "www.cisa.gov";
|
|
77
|
+
const KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
|
|
78
|
+
const KEV_LABEL = "cisa:kev";
|
|
79
|
+
const KEV_GATE_KEY = "cisa";
|
|
80
|
+
const KEV_GATE_MIN_INTERVAL_MS = 250;
|
|
81
|
+
// ★ S1 — the KEV read is DELIBERATELY UNBOUNDED. CISA sends NO Content-Length
|
|
82
|
+
// (chunked), so a content-length pre-check is inert; the read is bounded instead
|
|
83
|
+
// by the fixed host + `redirect:"error"` (fail-closed on any off-host 3xx) + the
|
|
84
|
+
// 30s AbortSignal timeout. `getJson`'s `r.json()` buffers the ~1.5 MB body.
|
|
85
|
+
const KEV_TIMEOUT_MS = 30_000;
|
|
86
|
+
// Plausibility floor (live 1637) — a truncated/near-empty catalog must NEVER read
|
|
87
|
+
// as "nothing is exploited" (the OFAC FLOORS doctrine applied to one catalog).
|
|
88
|
+
const KEV_FLOOR = 1_000;
|
|
89
|
+
// The lists update ~daily; a 6h TTL serves a warm cache instantly.
|
|
90
|
+
const KEV_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
91
|
+
// ─── Caller-input limits + validators ─────────────────────────────
|
|
92
|
+
const MAX_RESULTS_PER_PAGE = 2_000; // NVD's cap
|
|
93
|
+
const DEFAULT_RESULTS_PER_PAGE = 50;
|
|
94
|
+
const MAX_DATE_SPAN_DAYS = 120; // NVD 404s a >120-day span (clamp+disclose)
|
|
95
|
+
const MAX_KEYWORD_LEN = 512;
|
|
96
|
+
const MAX_CPE_LEN = 256;
|
|
97
|
+
/** Exact-CVE id shape (client-side; a malformed cveId 404s upstream). */
|
|
98
|
+
export const CVE_ID_RE = /^CVE-\d{4}-\d+$/;
|
|
99
|
+
/** A CPE 2.3 formatted string: the `cpe:2.3:[aho]:` prefix + printable-ASCII
|
|
100
|
+
* body (no spaces/control chars). Rejects `http://evil`, `../`, a trailing \n. */
|
|
101
|
+
const CPE_RE = /^cpe:2\.3:[aho]:[\x21-\x7e]{1,240}$/;
|
|
102
|
+
const CVSS_SEVERITIES = new Set(["LOW", "MEDIUM", "HIGH", "CRITICAL"]);
|
|
103
|
+
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
104
|
+
// ─── Optional NVD_API_KEY seam (header-carried, NEVER leaked) ─────
|
|
105
|
+
/** The ONLY reader of the key value (mirror `resolvedBlsKey`). Trimmed; empty ⇒
|
|
106
|
+
* keyless. Every other helper exposes only the MODE. */
|
|
107
|
+
export function resolvedNvdKey() {
|
|
108
|
+
const raw = process.env.NVD_API_KEY;
|
|
109
|
+
return typeof raw === "string" ? raw.trim() : "";
|
|
110
|
+
}
|
|
111
|
+
/** true when no NVD_API_KEY is configured (keyless). Drives mode + interval. */
|
|
112
|
+
export function usingNvdKey() {
|
|
113
|
+
return resolvedNvdKey() !== "";
|
|
114
|
+
}
|
|
115
|
+
function nvdKeyModeLabel() {
|
|
116
|
+
return usingNvdKey() ? "NVD_API_KEY" : "keyless";
|
|
117
|
+
}
|
|
118
|
+
function nvdMinIntervalMs() {
|
|
119
|
+
return usingNvdKey() ? NVD_MIN_INTERVAL_KEYED_MS : NVD_MIN_INTERVAL_KEYLESS_MS;
|
|
120
|
+
}
|
|
121
|
+
function nvdSource() {
|
|
122
|
+
return `services.nvd.nist.gov CVE API 2.0 (${nvdKeyModeLabel()})`;
|
|
123
|
+
}
|
|
124
|
+
// ─── Disclosure constants ─────────────────────────────────────────
|
|
125
|
+
/** M2 — the mandatory tier disclosure. Always names NVD_API_KEY; when keyless it
|
|
126
|
+
* also carries the free-registration escape. Rides on the reclassified rate throw
|
|
127
|
+
* AND on every response's _meta. */
|
|
128
|
+
function tierNote() {
|
|
129
|
+
const escape = usingNvdKey()
|
|
130
|
+
? ""
|
|
131
|
+
: " Set a free NVD_API_KEY (https://nvd.nist.gov/developers/request-an-api-key) to raise the limit to ~50 requests/30s — the key is sent ONLY in the apiKey HTTP header, never in a URL/label/_meta/log.";
|
|
132
|
+
return `NVD active rate tier: ${nvdKeyModeLabel()}. NVD's keyless public rate is ~5 requests/30s; a free NVD_API_KEY raises it to ~50 requests/30s.${escape} NVD returns HTTP 403/429 on a rate breach — retryable; back off ~30s.`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* ★ M3 — the load-bearing not-in-KEV ≠ safe caveat (VERBATIM shared constant,
|
|
136
|
+
* mirror `OFAC_NOT_DETERMINATION_NOTE`). Rides on cve_lookup's `kev.listed:false`
|
|
137
|
+
* AND on cisa_kev_lookup's `_meta.notes`. KEV is a CISA-curated subset of
|
|
138
|
+
* CONFIRMED in-the-wild exploitation; absence is NOT a safety clearance.
|
|
139
|
+
*/
|
|
140
|
+
export const KEV_NOT_SAFE_NOTE = "NOT in the CISA KEV catalog does NOT mean not-exploited, not-vulnerable, or safe — KEV is a CURATED SUBSET of CVEs with CISA-confirmed in-the-wild exploitation. Absence means CISA has not catalogued it, NOT that the component is unexploited or not vulnerable. Assess severity from the CVSS/CVE detail and your own analysis.";
|
|
141
|
+
const REJECTED_NOTE = "One or more CVE identifiers are REJECTED/withdrawn by their CNA (vulnStatus 'Rejected') — NOT live vulnerabilities; no CVSS is published (rejected:true, primaryCvss:null). Do not surface a rejected CVE as an active risk.";
|
|
142
|
+
const AWAITING_NOTE = "One or more CVEs are still being scored by NVD (vulnStatus 'Awaiting Analysis'/'Undergoing Analysis'/'Received') — a null CVSS is an honest UNKNOWN (NVD has not finished analysis), NOT zero risk.";
|
|
143
|
+
const SECONDARY_CVSS_NOTE = "One or more primaryCvss values are type 'Secondary' — only a CNA/Secondary CVSS exists (no NVD Primary 'nvd@nist.gov' score has been published yet). It is a real score, but not the NVD-authoritative Primary.";
|
|
144
|
+
/** Resolve the CVSS version from `cvssData.version`, else derive from the metric
|
|
145
|
+
* KEY (cvssMetricV31→"3.1", V30→"3.0", V2→"2.0", V40→"4.0"). */
|
|
146
|
+
function resolveCvssVersion(key, rawVersion) {
|
|
147
|
+
const v = str(rawVersion);
|
|
148
|
+
if (v !== null)
|
|
149
|
+
return v;
|
|
150
|
+
const m = /^cvssMetricV(\d)(\d)?$/.exec(key);
|
|
151
|
+
if (m)
|
|
152
|
+
return `${m[1]}.${m[2] ?? "0"}`;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/** Normalize a metric `type` to Primary/Secondary (else the raw string / null). */
|
|
156
|
+
function normCvssType(t) {
|
|
157
|
+
const s = str(t);
|
|
158
|
+
if (s === null)
|
|
159
|
+
return null;
|
|
160
|
+
const u = s.toLowerCase();
|
|
161
|
+
if (u === "primary")
|
|
162
|
+
return "Primary";
|
|
163
|
+
if (u === "secondary")
|
|
164
|
+
return "Secondary";
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
/** Numeric rank for version comparison (4.0 > 3.1 > 3.0 > 2.0). */
|
|
168
|
+
function versionRank(v) {
|
|
169
|
+
const n = Number.parseFloat(v ?? "");
|
|
170
|
+
return Number.isFinite(n) ? n : -1;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Extract EVERY `metrics` key matching `^cvssMetric` (V2/V30/V31/V40) into one
|
|
174
|
+
* `cvssMetrics[]` element per entry. `ssvcV203` and any non-cvssMetric key are
|
|
175
|
+
* IGNORED (never a "score"). `baseScore`/`baseSeverity` read from `cvssData`,
|
|
176
|
+
* with the V2 metric-level `baseSeverity` as the load-bearing fallback. Base
|
|
177
|
+
* score is null-never-0 (`num`).
|
|
178
|
+
*/
|
|
179
|
+
export function extractCvssMetrics(metrics) {
|
|
180
|
+
if (metrics === null || typeof metrics !== "object" || Array.isArray(metrics)) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const [key, val] of Object.entries(metrics)) {
|
|
185
|
+
if (!/^cvssMetric/.test(key))
|
|
186
|
+
continue; // excludes ssvcV203 + any non-CVSS key
|
|
187
|
+
if (!Array.isArray(val))
|
|
188
|
+
continue;
|
|
189
|
+
for (const entry of val) {
|
|
190
|
+
const e = (entry ?? {});
|
|
191
|
+
const cvssData = (e.cvssData ?? {});
|
|
192
|
+
out.push({
|
|
193
|
+
version: resolveCvssVersion(key, cvssData.version),
|
|
194
|
+
source: str(e.source),
|
|
195
|
+
type: normCvssType(e.type),
|
|
196
|
+
baseScore: num(cvssData.baseScore),
|
|
197
|
+
// V2 puts baseSeverity at the METRIC level (not inside cvssData) — the
|
|
198
|
+
// fallback is load-bearing (else V2 severity is lost).
|
|
199
|
+
baseSeverity: str(cvssData.baseSeverity) ?? str(e.baseSeverity),
|
|
200
|
+
vectorString: str(cvssData.vectorString),
|
|
201
|
+
exploitabilityScore: num(e.exploitabilityScore),
|
|
202
|
+
impactScore: num(e.impactScore),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* ★ M4 — `primaryCvss` is version-highest but type-PREFERENTIAL not
|
|
210
|
+
* type-EXCLUSIVE: pick the highest CVSS version overall, preferring Primary and
|
|
211
|
+
* FALLING BACK to the highest-version Secondary when no Primary exists. INCLUDES
|
|
212
|
+
* the `type`. Null ONLY when `cvssMetrics[]` is genuinely empty (Rejected/Awaiting).
|
|
213
|
+
*/
|
|
214
|
+
export function pickPrimaryCvss(metrics) {
|
|
215
|
+
if (metrics.length === 0)
|
|
216
|
+
return null;
|
|
217
|
+
const primaries = metrics.filter((m) => m.type === "Primary");
|
|
218
|
+
const secondaries = metrics.filter((m) => m.type === "Secondary");
|
|
219
|
+
const pool = primaries.length > 0
|
|
220
|
+
? primaries
|
|
221
|
+
: secondaries.length > 0
|
|
222
|
+
? secondaries
|
|
223
|
+
: metrics;
|
|
224
|
+
let best = pool[0];
|
|
225
|
+
for (const m of pool) {
|
|
226
|
+
if (versionRank(m.version) > versionRank(best.version))
|
|
227
|
+
best = m;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
version: best.version,
|
|
231
|
+
baseScore: best.baseScore,
|
|
232
|
+
baseSeverity: best.baseSeverity,
|
|
233
|
+
type: best.type,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// ─── CVE row helpers ──────────────────────────────────────────────
|
|
237
|
+
function pickDescription(descriptions) {
|
|
238
|
+
if (!Array.isArray(descriptions))
|
|
239
|
+
return null;
|
|
240
|
+
const en = descriptions.find((d) => d?.lang === "en");
|
|
241
|
+
const chosen = (en ?? descriptions[0]);
|
|
242
|
+
return str(chosen?.value);
|
|
243
|
+
}
|
|
244
|
+
function extractCwes(weaknesses) {
|
|
245
|
+
if (!Array.isArray(weaknesses))
|
|
246
|
+
return [];
|
|
247
|
+
const out = new Set();
|
|
248
|
+
for (const w of weaknesses) {
|
|
249
|
+
const descs = w?.description;
|
|
250
|
+
if (!Array.isArray(descs))
|
|
251
|
+
continue;
|
|
252
|
+
for (const d of descs) {
|
|
253
|
+
const v = str(d?.value);
|
|
254
|
+
if (v !== null && /^CWE-/i.test(v))
|
|
255
|
+
out.add(v);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return [...out];
|
|
259
|
+
}
|
|
260
|
+
function extractReferences(references) {
|
|
261
|
+
if (!Array.isArray(references))
|
|
262
|
+
return [];
|
|
263
|
+
const out = [];
|
|
264
|
+
for (const r of references) {
|
|
265
|
+
const url = str(r?.url);
|
|
266
|
+
if (url !== null)
|
|
267
|
+
out.push({ url, source: str(r.source) });
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
const REJECTED_STATUSES = new Set(["rejected"]);
|
|
272
|
+
const AWAITING_STATUSES = new Set([
|
|
273
|
+
"awaiting analysis",
|
|
274
|
+
"undergoing analysis",
|
|
275
|
+
"received",
|
|
276
|
+
]);
|
|
277
|
+
function strArrayLocal(x) {
|
|
278
|
+
if (!Array.isArray(x))
|
|
279
|
+
return [];
|
|
280
|
+
return x.map((v) => str(v)).filter((v) => v !== null);
|
|
281
|
+
}
|
|
282
|
+
function mapKevEntry(raw) {
|
|
283
|
+
const r = (raw ?? {});
|
|
284
|
+
return {
|
|
285
|
+
cveID: str(r.cveID),
|
|
286
|
+
vendorProject: str(r.vendorProject),
|
|
287
|
+
product: str(r.product),
|
|
288
|
+
vulnerabilityName: str(r.vulnerabilityName),
|
|
289
|
+
dateAdded: str(r.dateAdded),
|
|
290
|
+
shortDescription: str(r.shortDescription),
|
|
291
|
+
requiredAction: str(r.requiredAction),
|
|
292
|
+
dueDate: str(r.dueDate),
|
|
293
|
+
knownRansomwareCampaignUse: str(r.knownRansomwareCampaignUse),
|
|
294
|
+
notes: str(r.notes),
|
|
295
|
+
cwes: strArrayLocal(r.cwes),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Parse + validate the KEV catalog body → a loaded snapshot. Shape-guard FIRST
|
|
300
|
+
* (a non-object / missing `vulnerabilities` → driftError, never a fake empty),
|
|
301
|
+
* then the plausibility FLOOR + the `count === vulnerabilities.length` DRIFT
|
|
302
|
+
* guard. A truncated/drifted catalog THROWS (never read as "nothing exploited").
|
|
303
|
+
*/
|
|
304
|
+
export function parseKevCatalog(body, label) {
|
|
305
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
306
|
+
throw driftError(label, "CISA KEV catalog returned a body that is not an object — refusing to report it as an empty catalog.");
|
|
307
|
+
}
|
|
308
|
+
const b = body;
|
|
309
|
+
if (!Array.isArray(b.vulnerabilities)) {
|
|
310
|
+
throw driftError(label, "CISA KEV catalog is missing a `vulnerabilities` array (schema drift) — never a fake-empty catalog.");
|
|
311
|
+
}
|
|
312
|
+
const vulns = b.vulnerabilities;
|
|
313
|
+
if (vulns.length < KEV_FLOOR) {
|
|
314
|
+
throw driftError(label, `CISA KEV catalog parsed ${vulns.length} entries, below the plausibility floor of ${KEV_FLOOR} — the download is truncated or drifted. Refusing (a near-empty KEV catalog must never read as "nothing is exploited").`);
|
|
315
|
+
}
|
|
316
|
+
// `count` must be a number AND equal the array length (a drift/truncation anchor).
|
|
317
|
+
if (typeof b.count !== "number") {
|
|
318
|
+
throw driftError(label, "CISA KEV catalog `count` is absent/non-numeric — nothing trustworthy to anchor the drift guard (schema drift).");
|
|
319
|
+
}
|
|
320
|
+
if (b.count !== vulns.length) {
|
|
321
|
+
throw driftError(label, `CISA KEV catalog count (${b.count}) !== vulnerabilities.length (${vulns.length}) — a truncated or drifted catalog. Refusing (a partial catalog must never read as complete).`);
|
|
322
|
+
}
|
|
323
|
+
const entries = vulns.map(mapKevEntry);
|
|
324
|
+
const byCve = new Map();
|
|
325
|
+
for (const e of entries) {
|
|
326
|
+
if (e.cveID !== null)
|
|
327
|
+
byCve.set(e.cveID.toUpperCase(), e);
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
entries,
|
|
331
|
+
byCve,
|
|
332
|
+
catalogVersion: str(b.catalogVersion),
|
|
333
|
+
dateReleased: str(b.dateReleased),
|
|
334
|
+
count: b.count,
|
|
335
|
+
fetchedAt: Date.now(),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// ─── KEV module cache (fetch once + freshness; single in-flight promise) ──
|
|
339
|
+
let kevCache = null;
|
|
340
|
+
let kevInFlight = null;
|
|
341
|
+
/** TEST-ONLY: drop the KEV cache so a fault case can re-point the fetch mock. */
|
|
342
|
+
export function _resetNvdCacheForTests() {
|
|
343
|
+
kevCache = null;
|
|
344
|
+
kevInFlight = null;
|
|
345
|
+
}
|
|
346
|
+
/** Fetch + parse the KEV catalog (SSRF-locked, redirect-fail-closed). Every
|
|
347
|
+
* failure THROWS a classified error (never a fake-empty catalog). */
|
|
348
|
+
async function fetchKevCatalog() {
|
|
349
|
+
// SSRF belt: the URL is a compile-time constant; assert it cannot have drifted.
|
|
350
|
+
const built = new URL(KEV_URL);
|
|
351
|
+
if (built.hostname !== KEV_HOST || built.protocol !== "https:") {
|
|
352
|
+
throw new ToolErrorCarrier({
|
|
353
|
+
kind: "invalid_input",
|
|
354
|
+
message: `Constructed KEV URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${KEV_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
355
|
+
retryable: false,
|
|
356
|
+
upstreamEndpoint: KEV_LABEL,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
let body;
|
|
360
|
+
try {
|
|
361
|
+
body = await throughGate(KEV_GATE_KEY, KEV_GATE_MIN_INTERVAL_MS, () => getJson(KEV_URL, {
|
|
362
|
+
label: KEV_LABEL,
|
|
363
|
+
redirect: "error",
|
|
364
|
+
timeoutMs: KEV_TIMEOUT_MS,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
if (e instanceof ToolErrorCarrier)
|
|
369
|
+
throw e;
|
|
370
|
+
if (e instanceof SyntaxError) {
|
|
371
|
+
throw driftError(KEV_LABEL, "CISA KEV returned a non-JSON body at HTTP 200 (an HTML error page) — treating as schema drift (never read as an empty catalog).");
|
|
372
|
+
}
|
|
373
|
+
throw new ToolErrorCarrier({
|
|
374
|
+
kind: "upstream_unavailable",
|
|
375
|
+
message: `Network error fetching the CISA KEV catalog: ${e.message}. The service is unavailable, NOT an empty catalog — retry (never read a fetch failure as "not exploited").`,
|
|
376
|
+
retryable: true,
|
|
377
|
+
retryAfterSeconds: 30,
|
|
378
|
+
upstreamEndpoint: KEV_LABEL,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return parseKevCatalog(body, KEV_LABEL);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Ensure the KEV catalog is loaded + fresh. A warm cache within TTL is served
|
|
385
|
+
* instantly; a past-TTL refresh that FAILS while a prior copy exists keeps the
|
|
386
|
+
* stale copy (real data, disclosed by age). A COLD miss whose fetch fails THROWS
|
|
387
|
+
* (never a fabricated catalog). Single in-flight promise (no thundering herd).
|
|
388
|
+
*/
|
|
389
|
+
async function ensureKev() {
|
|
390
|
+
if (kevCache && Date.now() - kevCache.fetchedAt <= KEV_CACHE_TTL_MS) {
|
|
391
|
+
return kevCache;
|
|
392
|
+
}
|
|
393
|
+
if (kevInFlight)
|
|
394
|
+
return kevInFlight;
|
|
395
|
+
const prior = kevCache;
|
|
396
|
+
const load = (async () => {
|
|
397
|
+
try {
|
|
398
|
+
return await fetchKevCatalog();
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
if (prior)
|
|
402
|
+
return prior; // stale copy served (honest, disclosed by age)
|
|
403
|
+
throw e;
|
|
404
|
+
}
|
|
405
|
+
})();
|
|
406
|
+
kevInFlight = load;
|
|
407
|
+
try {
|
|
408
|
+
const loaded = await load;
|
|
409
|
+
kevCache = loaded;
|
|
410
|
+
return loaded;
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
kevInFlight = null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// ─── NVD transport (SSRF assert + M2 rate reclassify) ─────────────
|
|
417
|
+
/**
|
|
418
|
+
* GET the NVD CVE API with the given query params. Fixed host; the OPTIONAL key
|
|
419
|
+
* rides ONLY in the `apiKey` header. `redirect:"error"` fails closed.
|
|
420
|
+
*
|
|
421
|
+
* ★ M2 — a keyless rate breach is HTTP 403 (live-confirmed), OR 429. The reused
|
|
422
|
+
* `errorFromResponse` classifies a bare 403 as `invalid_input` (permanent, no
|
|
423
|
+
* back-off) — so at THIS call site we reclassify a caught 403/429 to
|
|
424
|
+
* `rate_limited` carrying `tierNote()` (the ~5/30s keyless vs ~50/30s keyed
|
|
425
|
+
* disclosure + the free-registration escape). 404→not_found and other 4xx→
|
|
426
|
+
* invalid_input stay unchanged.
|
|
427
|
+
*/
|
|
428
|
+
async function nvdGet(params) {
|
|
429
|
+
const url = `${NVD_CVES_URL}?${params.toString()}`;
|
|
430
|
+
const built = new URL(url);
|
|
431
|
+
if (built.hostname !== NVD_HOST || built.protocol !== "https:") {
|
|
432
|
+
throw new ToolErrorCarrier({
|
|
433
|
+
kind: "invalid_input",
|
|
434
|
+
message: `Constructed NVD URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${NVD_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
435
|
+
retryable: false,
|
|
436
|
+
upstreamEndpoint: NVD_LABEL,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const headers = { "User-Agent": NVD_UA };
|
|
440
|
+
const key = resolvedNvdKey();
|
|
441
|
+
// The OPTIONAL key — injected ONLY here, in the apiKey header, and NOWHERE else.
|
|
442
|
+
if (key !== "")
|
|
443
|
+
headers.apiKey = key;
|
|
444
|
+
try {
|
|
445
|
+
return await throughGate(NVD_GATE_KEY, nvdMinIntervalMs(), () => getJson(built.toString(), {
|
|
446
|
+
label: NVD_LABEL,
|
|
447
|
+
headers,
|
|
448
|
+
redirect: "error",
|
|
449
|
+
timeoutMs: NVD_TIMEOUT_MS,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
if (e instanceof ToolErrorCarrier) {
|
|
454
|
+
const status = e.toolError.upstreamStatus;
|
|
455
|
+
if (status === 403 || status === 429) {
|
|
456
|
+
// ★ M2 — a keyless over-rate (403) or 429 → rate_limited + the tier
|
|
457
|
+
// disclosure (never a permanent invalid_input, never a fake-empty).
|
|
458
|
+
throw new ToolErrorCarrier({
|
|
459
|
+
kind: "rate_limited",
|
|
460
|
+
message: tierNote(),
|
|
461
|
+
retryable: true,
|
|
462
|
+
retryAfterSeconds: 30,
|
|
463
|
+
upstreamStatus: status,
|
|
464
|
+
upstreamEndpoint: NVD_LABEL,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
throw e;
|
|
468
|
+
}
|
|
469
|
+
if (e instanceof SyntaxError) {
|
|
470
|
+
throw driftError(NVD_LABEL, "NVD returned a non-JSON body at HTTP 200 (an HTML error page) — treating as schema drift (never read as an empty result).");
|
|
471
|
+
}
|
|
472
|
+
throw e;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function nvdInvalid(message) {
|
|
476
|
+
throw new ToolErrorCarrier({
|
|
477
|
+
kind: "invalid_input",
|
|
478
|
+
message,
|
|
479
|
+
retryable: false,
|
|
480
|
+
upstreamEndpoint: NVD_LABEL,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
/** Validate an ISO date (YYYY-MM-DD) → a Date at UTC midnight; else invalid_input. */
|
|
484
|
+
function parseIsoDate(raw, field) {
|
|
485
|
+
if (!DATE_ONLY_RE.test(raw)) {
|
|
486
|
+
nvdInvalid(`Invalid ${field} ${JSON.stringify(raw)} — expected an ISO date YYYY-MM-DD.`);
|
|
487
|
+
}
|
|
488
|
+
const t = Date.parse(`${raw}T00:00:00.000Z`);
|
|
489
|
+
if (!Number.isFinite(t)) {
|
|
490
|
+
nvdInvalid(`Invalid ${field} ${JSON.stringify(raw)} — not a real calendar date.`);
|
|
491
|
+
}
|
|
492
|
+
return new Date(t);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Resolve one date pair (all-or-nothing — NVD 404s a lone bound) → NVD-format
|
|
496
|
+
* bounds, clamping the START forward to a ≤120-day window and returning a
|
|
497
|
+
* disclosure note when clamped.
|
|
498
|
+
*/
|
|
499
|
+
function resolveDatePair(startRaw, endRaw, startParam, endParam, humanLabel) {
|
|
500
|
+
const hasStart = startRaw !== undefined && startRaw !== "";
|
|
501
|
+
const hasEnd = endRaw !== undefined && endRaw !== "";
|
|
502
|
+
if (!hasStart && !hasEnd)
|
|
503
|
+
return null;
|
|
504
|
+
if (hasStart !== hasEnd) {
|
|
505
|
+
nvdInvalid(`${humanLabel} date range must be paired — provide BOTH ${startParam} and ${endParam} (NVD 404s a lone bound).`);
|
|
506
|
+
}
|
|
507
|
+
const startDate = parseIsoDate(startRaw, startParam);
|
|
508
|
+
const endDate = parseIsoDate(endRaw, endParam);
|
|
509
|
+
if (startDate.getTime() > endDate.getTime()) {
|
|
510
|
+
nvdInvalid(`${startParam} (${startRaw}) must be ≤ ${endParam} (${endRaw}).`);
|
|
511
|
+
}
|
|
512
|
+
const spanDays = (endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000);
|
|
513
|
+
let sentStart = startDate;
|
|
514
|
+
let note = null;
|
|
515
|
+
if (spanDays > MAX_DATE_SPAN_DAYS) {
|
|
516
|
+
sentStart = new Date(endDate.getTime() - MAX_DATE_SPAN_DAYS * 24 * 60 * 60 * 1000);
|
|
517
|
+
const sentStartIso = sentStart.toISOString().slice(0, 10);
|
|
518
|
+
note = `The requested ${humanLabel} span ${startRaw}–${endRaw} (${Math.round(spanDays)} days) exceeds NVD's ${MAX_DATE_SPAN_DAYS}-day/query cap; ${startParam} was clamped forward to ${sentStartIso} BEFORE the request (sent ${sentStartIso}–${endRaw}). Narrow the window to page the earlier range.`;
|
|
519
|
+
}
|
|
520
|
+
// NVD expects an ISO-8601 date-time; use UTC midnight / end-of-day.
|
|
521
|
+
return {
|
|
522
|
+
start: `${sentStart.toISOString().slice(0, 10)}T00:00:00.000`,
|
|
523
|
+
end: `${endDate.toISOString().slice(0, 10)}T23:59:59.999`,
|
|
524
|
+
note,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* NVD CVE exact-lookup (`cveId`) OR search (keyword/cpeName/cvssV3Severity/
|
|
529
|
+
* date-range), each row ANNOTATED with its CISA KEV status. Honest `_meta`:
|
|
530
|
+
* totalAvailable = totalResults (exact), within-window pagination, tierNote, KEV
|
|
531
|
+
* freshness, the not-in-KEV caveat, span-clamp/rejected notes as applicable.
|
|
532
|
+
*
|
|
533
|
+
* ★ M1 — when `kevOnly` is truthy AND the KEV catalog failed to load, THROW the
|
|
534
|
+
* classified KEV error (a KEV-membership filter is unanswerable without a loaded
|
|
535
|
+
* catalog; a silently-empty result would be a fake "none on the mandatory list").
|
|
536
|
+
*/
|
|
537
|
+
export async function cveLookup(args) {
|
|
538
|
+
const params = new URLSearchParams();
|
|
539
|
+
const filtersApplied = [];
|
|
540
|
+
const clampNotes = [];
|
|
541
|
+
// ── Validate + build the query params (SSRF: all caller inputs are query
|
|
542
|
+
// params, validated then URLSearchParams-encoded; host/path unreachable). ──
|
|
543
|
+
const cveId = args.cveId?.trim();
|
|
544
|
+
if (cveId !== undefined && cveId !== "") {
|
|
545
|
+
if (!CVE_ID_RE.test(cveId)) {
|
|
546
|
+
nvdInvalid(`Invalid cveId ${JSON.stringify(cveId)} — expected the exact form CVE-YYYY-NNNN (^CVE-\\d{4}-\\d+$). A malformed cveId 404s upstream.`);
|
|
547
|
+
}
|
|
548
|
+
params.set("cveId", cveId);
|
|
549
|
+
filtersApplied.push(`cveId(${cveId})`);
|
|
550
|
+
}
|
|
551
|
+
const keyword = args.keyword?.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
552
|
+
if (keyword !== undefined && keyword !== "") {
|
|
553
|
+
if (keyword.length > MAX_KEYWORD_LEN) {
|
|
554
|
+
nvdInvalid(`keyword too long (${keyword.length} chars, max ${MAX_KEYWORD_LEN}).`);
|
|
555
|
+
}
|
|
556
|
+
params.set("keywordSearch", keyword);
|
|
557
|
+
filtersApplied.push(`keyword(${keyword})`);
|
|
558
|
+
}
|
|
559
|
+
const cpeName = args.cpeName?.trim();
|
|
560
|
+
if (cpeName !== undefined && cpeName !== "") {
|
|
561
|
+
if (cpeName.length > MAX_CPE_LEN || !CPE_RE.test(cpeName)) {
|
|
562
|
+
nvdInvalid(`Invalid cpeName ${JSON.stringify(cpeName)} — expected a CPE 2.3 formatted string (cpe:2.3:[aho]:… — e.g. cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*). Rejects non-CPE input (SSRF + verify-the-input honesty).`);
|
|
563
|
+
}
|
|
564
|
+
params.set("cpeName", cpeName);
|
|
565
|
+
filtersApplied.push(`cpeName(${cpeName})`);
|
|
566
|
+
}
|
|
567
|
+
const sev = args.cvssV3Severity?.trim().toUpperCase();
|
|
568
|
+
if (sev !== undefined && sev !== "") {
|
|
569
|
+
if (!CVSS_SEVERITIES.has(sev)) {
|
|
570
|
+
nvdInvalid(`Invalid cvssV3Severity ${JSON.stringify(args.cvssV3Severity)} — expected one of: LOW, MEDIUM, HIGH, CRITICAL.`);
|
|
571
|
+
}
|
|
572
|
+
params.set("cvssV3Severity", sev);
|
|
573
|
+
filtersApplied.push(`cvssV3Severity(${sev})`);
|
|
574
|
+
}
|
|
575
|
+
const pubPair = resolveDatePair(args.pubStartDate, args.pubEndDate, "pubStartDate", "pubEndDate", "publication");
|
|
576
|
+
if (pubPair) {
|
|
577
|
+
params.set("pubStartDate", pubPair.start);
|
|
578
|
+
params.set("pubEndDate", pubPair.end);
|
|
579
|
+
filtersApplied.push(`pubStartDate(${pubPair.start})`, `pubEndDate(${pubPair.end})`);
|
|
580
|
+
if (pubPair.note)
|
|
581
|
+
clampNotes.push(pubPair.note);
|
|
582
|
+
}
|
|
583
|
+
const modPair = resolveDatePair(args.lastModStartDate, args.lastModEndDate, "lastModStartDate", "lastModEndDate", "last-modified");
|
|
584
|
+
if (modPair) {
|
|
585
|
+
params.set("lastModStartDate", modPair.start);
|
|
586
|
+
params.set("lastModEndDate", modPair.end);
|
|
587
|
+
filtersApplied.push(`lastModStartDate(${modPair.start})`, `lastModEndDate(${modPair.end})`);
|
|
588
|
+
if (modPair.note)
|
|
589
|
+
clampNotes.push(modPair.note);
|
|
590
|
+
}
|
|
591
|
+
// ── At least one selector is required (never a silent full-DB pull). ──
|
|
592
|
+
if (!params.has("cveId") &&
|
|
593
|
+
!params.has("keywordSearch") &&
|
|
594
|
+
!params.has("cpeName") &&
|
|
595
|
+
!params.has("cvssV3Severity") &&
|
|
596
|
+
!params.has("pubStartDate") &&
|
|
597
|
+
!params.has("lastModStartDate")) {
|
|
598
|
+
nvdInvalid("At least one of cveId / keyword / cpeName / cvssV3Severity / a date range is required — nothing to look up (never a silent full-database pull).");
|
|
599
|
+
}
|
|
600
|
+
// ── Pagination (resultsPerPage ≤ 2000; startIndex ≥ 0). ──
|
|
601
|
+
const rppRaw = args.resultsPerPage ?? DEFAULT_RESULTS_PER_PAGE;
|
|
602
|
+
if (!Number.isFinite(rppRaw) || rppRaw < 1) {
|
|
603
|
+
nvdInvalid(`resultsPerPage must be a positive integer (got ${rppRaw}).`);
|
|
604
|
+
}
|
|
605
|
+
if (rppRaw > MAX_RESULTS_PER_PAGE) {
|
|
606
|
+
nvdInvalid(`resultsPerPage ${rppRaw} exceeds NVD's cap of ${MAX_RESULTS_PER_PAGE} — reduce it (never a silent clamp).`);
|
|
607
|
+
}
|
|
608
|
+
const resultsPerPage = Math.floor(rppRaw);
|
|
609
|
+
const startIndexRaw = args.startIndex ?? 0;
|
|
610
|
+
if (!Number.isFinite(startIndexRaw) || startIndexRaw < 0) {
|
|
611
|
+
nvdInvalid(`startIndex must be ≥ 0 (got ${startIndexRaw}).`);
|
|
612
|
+
}
|
|
613
|
+
const startIndex = Math.floor(startIndexRaw);
|
|
614
|
+
params.set("resultsPerPage", String(resultsPerPage));
|
|
615
|
+
params.set("startIndex", String(startIndex));
|
|
616
|
+
const kevOnly = args.kevOnly === true;
|
|
617
|
+
// ── Load the KEV catalog (capture the error rather than fail the whole call —
|
|
618
|
+
// a valid CVSS is still useful when KEV is down). ──
|
|
619
|
+
let kevLoaded = null;
|
|
620
|
+
let kevError = null;
|
|
621
|
+
try {
|
|
622
|
+
kevLoaded = await ensureKev();
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
kevError = e;
|
|
626
|
+
}
|
|
627
|
+
// ★ M1 — kevOnly during a KEV outage is UNANSWERABLE → THROW (never an empty
|
|
628
|
+
// filtered set that reads as "none of these CVEs are on the mandatory list").
|
|
629
|
+
if (kevOnly && kevLoaded === null) {
|
|
630
|
+
if (kevError instanceof ToolErrorCarrier)
|
|
631
|
+
throw kevError;
|
|
632
|
+
throw new ToolErrorCarrier({
|
|
633
|
+
kind: "upstream_unavailable",
|
|
634
|
+
message: `kevOnly filtering requires the CISA KEV catalog, which could not be loaded${kevError instanceof Error ? `: ${kevError.message}` : ""}. A KEV-membership filter is unanswerable without a loaded catalog — refusing to return an empty list (which would falsely read as "none of these CVEs are on CISA's mandatory-remediation list"). Retry, or drop kevOnly to get the CVSS detail with KEV status degraded to unknown.`,
|
|
635
|
+
retryable: true,
|
|
636
|
+
retryAfterSeconds: 30,
|
|
637
|
+
upstreamEndpoint: KEV_LABEL,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
// ── The NVD fetch (P2: 403/429 → rate_limited THROW; 404/5xx/timeout → THROW;
|
|
641
|
+
// a genuine totalResults:0 is honest). ──
|
|
642
|
+
const body = await nvdGet(params);
|
|
643
|
+
// ── Shape guard FIRST → THROW (never fake-empty). ──
|
|
644
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
645
|
+
throw driftError(NVD_LABEL, "NVD returned a 200 body that is not an object — refusing to report it as an empty result.");
|
|
646
|
+
}
|
|
647
|
+
const b = body;
|
|
648
|
+
// ★ P1 — totalResults must be a NUMBER (a non-number → driftError BEFORE num()).
|
|
649
|
+
if (typeof b.totalResults !== "number") {
|
|
650
|
+
throw driftError(NVD_LABEL, "NVD totalResults is absent/non-numeric — nothing trustworthy to report as a total (schema drift).");
|
|
651
|
+
}
|
|
652
|
+
if (!Array.isArray(b.vulnerabilities)) {
|
|
653
|
+
throw driftError(NVD_LABEL, "NVD `vulnerabilities` is missing/non-array (schema drift) — never a fake-empty result.");
|
|
654
|
+
}
|
|
655
|
+
const totalResults = num(b.totalResults); // exact count; never vulnerabilities.length
|
|
656
|
+
// ── Map + KEV-annotate each row. ──
|
|
657
|
+
const anyRejected = [];
|
|
658
|
+
const anyAwaiting = [];
|
|
659
|
+
let anyKevListedFalse = false;
|
|
660
|
+
let anyKevUnavailable = false;
|
|
661
|
+
let anySecondaryPrimary = false;
|
|
662
|
+
const rows = b.vulnerabilities.map((v) => {
|
|
663
|
+
const cve = (v?.cve ?? {});
|
|
664
|
+
const cvssMetrics = extractCvssMetrics(cve.metrics);
|
|
665
|
+
const primaryCvss = pickPrimaryCvss(cvssMetrics);
|
|
666
|
+
if (primaryCvss && primaryCvss.type === "Secondary")
|
|
667
|
+
anySecondaryPrimary = true;
|
|
668
|
+
const vulnStatus = str(cve.vulnStatus);
|
|
669
|
+
const statusLc = (vulnStatus ?? "").toLowerCase();
|
|
670
|
+
const rejected = REJECTED_STATUSES.has(statusLc);
|
|
671
|
+
if (rejected)
|
|
672
|
+
anyRejected.push(true);
|
|
673
|
+
if (AWAITING_STATUSES.has(statusLc))
|
|
674
|
+
anyAwaiting.push(true);
|
|
675
|
+
const id = str(cve.id);
|
|
676
|
+
// ── KEV annotation (listed:null on outage — NEVER false). ──
|
|
677
|
+
let kev;
|
|
678
|
+
if (kevLoaded === null) {
|
|
679
|
+
kev = { listed: null, status: "unavailable" };
|
|
680
|
+
anyKevUnavailable = true;
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
const hit = id !== null ? kevLoaded.byCve.get(id.toUpperCase()) : undefined;
|
|
684
|
+
if (hit) {
|
|
685
|
+
kev = {
|
|
686
|
+
listed: true,
|
|
687
|
+
dateAdded: hit.dateAdded,
|
|
688
|
+
dueDate: hit.dueDate,
|
|
689
|
+
ransomware: hit.knownRansomwareCampaignUse, // verbatim (Known/Unknown)
|
|
690
|
+
requiredAction: hit.requiredAction,
|
|
691
|
+
catalogVersion: kevLoaded.catalogVersion,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
kev = { listed: false, note: KEV_NOT_SAFE_NOTE };
|
|
696
|
+
anyKevListedFalse = true;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
cveId: id,
|
|
701
|
+
vulnStatus,
|
|
702
|
+
rejected,
|
|
703
|
+
published: str(cve.published),
|
|
704
|
+
lastModified: str(cve.lastModified),
|
|
705
|
+
description: pickDescription(cve.descriptions),
|
|
706
|
+
cvssMetrics,
|
|
707
|
+
primaryCvss,
|
|
708
|
+
cwes: extractCwes(cve.weaknesses),
|
|
709
|
+
references: extractReferences(cve.references),
|
|
710
|
+
kev,
|
|
711
|
+
};
|
|
712
|
+
});
|
|
713
|
+
// ★ D1 — apply the kevOnly KEV-listed post-filter ONLY on a LOADED catalog (the
|
|
714
|
+
// M1 guard above already THREW on the outage case). `kev.listed===true` keeps
|
|
715
|
+
// only KEV-listed rows; `listed:false`/`null` are dropped.
|
|
716
|
+
const kevListedFilter = kevOnly && kevLoaded !== null;
|
|
717
|
+
const kevFiltered = kevListedFilter
|
|
718
|
+
? rows.filter((r) => r.kev.listed === true)
|
|
719
|
+
: rows;
|
|
720
|
+
// `nvdPageSize` = the NVD rows CONSUMED on this page (drives pagination);
|
|
721
|
+
// `returned` = the DISPLAYED rows (post-KEV-filter). They differ under kevOnly.
|
|
722
|
+
const nvdPageSize = rows.length;
|
|
723
|
+
const returned = kevFiltered.length;
|
|
724
|
+
// ★ CRITICAL pagination honesty (D1): nextOffset/hasMore MUST derive from the
|
|
725
|
+
// NVD page size (pre-KEV-filter), NEVER `kevFiltered.length`. kevOnly is a
|
|
726
|
+
// client-side post-filter over the CURRENT page and KEV-listed CVEs are
|
|
727
|
+
// scattered across NVD pages — deriving hasMore from the filtered length would
|
|
728
|
+
// dead-end a page with 0 KEV-listed rows and SILENTLY HIDE KEV-listed rows on
|
|
729
|
+
// later pages (a false-empty / silent-drop). Keep paging NVD while NVD has more.
|
|
730
|
+
const candidateNext = startIndex + nvdPageSize;
|
|
731
|
+
const nextOffset = totalResults !== null && candidateNext >= totalResults ? null : candidateNext;
|
|
732
|
+
const hasMore = nextOffset !== null && nvdPageSize > 0;
|
|
733
|
+
// ── Assemble notes. ──
|
|
734
|
+
const notes = [tierNote()];
|
|
735
|
+
if (kevLoaded !== null) {
|
|
736
|
+
const ageHours = Math.round(((Date.now() - kevLoaded.fetchedAt) / 3_600_000) * 10) / 10;
|
|
737
|
+
notes.push(`CISA KEV catalog version ${kevLoaded.catalogVersion ?? "unknown"} (released ${kevLoaded.dateReleased ?? "unknown"}), ${kevLoaded.count} entries, loaded ${ageHours}h ago (TTL 6h). Each CVE is annotated with its KEV status; kev.dueDate is the CISA-mandated remediation deadline and kev.ransomware is surfaced verbatim (Known/Unknown).`);
|
|
738
|
+
}
|
|
739
|
+
if (anyKevUnavailable) {
|
|
740
|
+
notes.push("The CISA KEV catalog could not be loaded, so KEV status is UNKNOWN for each CVE (kev.listed:null, NEVER false) — a KEV outage can NEVER be read as 'this CVE is not on CISA's mandatory-remediation list'. The NVD/CVSS detail below is real; retry for KEV annotation.");
|
|
741
|
+
}
|
|
742
|
+
if (anyKevListedFalse)
|
|
743
|
+
notes.push(KEV_NOT_SAFE_NOTE);
|
|
744
|
+
if (anySecondaryPrimary)
|
|
745
|
+
notes.push(SECONDARY_CVSS_NOTE);
|
|
746
|
+
if (anyRejected.length > 0)
|
|
747
|
+
notes.push(REJECTED_NOTE);
|
|
748
|
+
if (anyAwaiting.length > 0)
|
|
749
|
+
notes.push(AWAITING_NOTE);
|
|
750
|
+
notes.push(...clampNotes);
|
|
751
|
+
const isExact = params.has("cveId");
|
|
752
|
+
// The genuine-NVD-empty note keys on the NVD page size (nvdPageSize===0), NOT
|
|
753
|
+
// the post-filter `returned` — a kevOnly-filtered-out CVE was PRESENT in NVD.
|
|
754
|
+
if (isExact && nvdPageSize === 0) {
|
|
755
|
+
notes.push(`${cveId} was not found in NVD (totalResults:0) — a GENUINE empty (found:false), not an error. Verify the identifier; a very-new CVE may not be published yet.`);
|
|
756
|
+
}
|
|
757
|
+
if (kevListedFilter) {
|
|
758
|
+
notes.push(`kevOnly is a CLIENT-SIDE filter applied to THIS NVD page: ${nvdPageSize} NVD row(s) were scanned and ${returned} are KEV-listed (kev.listed:true; the rest were dropped). totalAvailable is the NVD (pre-KEV-filter) total, NOT the count of KEV-listed matches — that count is UNKNOWABLE without paging the whole set. Keep paging while _meta.pagination.hasMore is true (nextOffset is NVD-based) to collect all KEV-listed rows — a page can have hasMore:true with returned:0 (0 KEV-listed on this page but more NVD pages remain).`);
|
|
759
|
+
}
|
|
760
|
+
const data = { results: kevFiltered };
|
|
761
|
+
if (isExact)
|
|
762
|
+
data.found = returned > 0; // under kevOnly: found iff in NVD *and* in KEV
|
|
763
|
+
if (kevListedFilter)
|
|
764
|
+
data.scannedOnPage = nvdPageSize; // NVD rows scanned (pagination math)
|
|
765
|
+
const metaOut = {
|
|
766
|
+
source: nvdSource(),
|
|
767
|
+
keylessMode: !usingNvdKey(),
|
|
768
|
+
returned,
|
|
769
|
+
totalAvailable: totalResults,
|
|
770
|
+
// "kevOnly" is listed ONLY when the filter actually RAN (loaded catalog) — an
|
|
771
|
+
// outage would have thrown at the M1 guard, so this never claims an unrun filter.
|
|
772
|
+
filtersApplied: kevListedFilter ? [...filtersApplied, "kevOnly"] : filtersApplied,
|
|
773
|
+
filtersDropped: [],
|
|
774
|
+
fieldsUnavailable: anyKevUnavailable ? ["kev"] : [],
|
|
775
|
+
pagination: { offset: startIndex, limit: resultsPerPage, nextOffset, hasMore },
|
|
776
|
+
notes,
|
|
777
|
+
};
|
|
778
|
+
return withMeta(data, metaOut);
|
|
779
|
+
}
|
|
780
|
+
function kevInvalid(message) {
|
|
781
|
+
throw new ToolErrorCarrier({
|
|
782
|
+
kind: "invalid_input",
|
|
783
|
+
message,
|
|
784
|
+
retryable: false,
|
|
785
|
+
upstreamEndpoint: KEV_LABEL,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const KEV_MAX_LIMIT = 1_000;
|
|
789
|
+
const KEV_DEFAULT_LIMIT = 100;
|
|
790
|
+
function nvdDetailUrl(cveID) {
|
|
791
|
+
return `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(cveID)}`;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Filter the cached CISA KEV catalog standalone (by cveId / vendorProject /
|
|
795
|
+
* product / ransomwareOnly / addedSince / dueBefore, paginated). THROWS on a
|
|
796
|
+
* catalog outage / floor-fail / drift (never a fake-empty catalog).
|
|
797
|
+
*
|
|
798
|
+
* ★ M3 — the not-in-KEV ≠ safe caveat (KEV_NOT_SAFE_NOTE) rides in _meta.notes on
|
|
799
|
+
* EVERY response (especially a `cveId` miss / zero-match): absence is NOT a safety
|
|
800
|
+
* clearance.
|
|
801
|
+
*/
|
|
802
|
+
export async function cisaKevLookup(args) {
|
|
803
|
+
// Validate inputs BEFORE the catalog load (a bad cveId is a caller error).
|
|
804
|
+
const cveId = args.cveId?.trim();
|
|
805
|
+
if (cveId !== undefined && cveId !== "" && !CVE_ID_RE.test(cveId)) {
|
|
806
|
+
kevInvalid(`Invalid cveId ${JSON.stringify(cveId)} — expected the exact form CVE-YYYY-NNNN (^CVE-\\d{4}-\\d+$).`);
|
|
807
|
+
}
|
|
808
|
+
const addedSince = args.addedSince?.trim();
|
|
809
|
+
if (addedSince !== undefined && addedSince !== "" && !DATE_ONLY_RE.test(addedSince)) {
|
|
810
|
+
kevInvalid(`Invalid addedSince ${JSON.stringify(addedSince)} — expected ISO YYYY-MM-DD.`);
|
|
811
|
+
}
|
|
812
|
+
const dueBefore = args.dueBefore?.trim();
|
|
813
|
+
if (dueBefore !== undefined && dueBefore !== "" && !DATE_ONLY_RE.test(dueBefore)) {
|
|
814
|
+
kevInvalid(`Invalid dueBefore ${JSON.stringify(dueBefore)} — expected ISO YYYY-MM-DD.`);
|
|
815
|
+
}
|
|
816
|
+
const limitRaw = args.limit ?? KEV_DEFAULT_LIMIT;
|
|
817
|
+
if (!Number.isFinite(limitRaw) || limitRaw < 1 || limitRaw > KEV_MAX_LIMIT) {
|
|
818
|
+
kevInvalid(`limit must be between 1 and ${KEV_MAX_LIMIT} (got ${limitRaw}).`);
|
|
819
|
+
}
|
|
820
|
+
const limit = Math.floor(limitRaw);
|
|
821
|
+
const offsetRaw = args.offset ?? 0;
|
|
822
|
+
if (!Number.isFinite(offsetRaw) || offsetRaw < 0) {
|
|
823
|
+
kevInvalid(`offset must be ≥ 0 (got ${offsetRaw}).`);
|
|
824
|
+
}
|
|
825
|
+
const offset = Math.floor(offsetRaw);
|
|
826
|
+
// ── Load the catalog — THROWS on outage / floor / drift (never fake-empty). ──
|
|
827
|
+
const kev = await ensureKev();
|
|
828
|
+
// ── Client-side filters over the cached catalog (zero wire surface). ──
|
|
829
|
+
const vendorProject = args.vendorProject?.trim().toLowerCase();
|
|
830
|
+
const product = args.product?.trim().toLowerCase();
|
|
831
|
+
const ransomwareOnly = args.ransomwareOnly === true;
|
|
832
|
+
const filtersApplied = [];
|
|
833
|
+
if (cveId)
|
|
834
|
+
filtersApplied.push(`cveId(${cveId})`);
|
|
835
|
+
if (vendorProject)
|
|
836
|
+
filtersApplied.push(`vendorProject(${args.vendorProject})`);
|
|
837
|
+
if (product)
|
|
838
|
+
filtersApplied.push(`product(${args.product})`);
|
|
839
|
+
if (ransomwareOnly)
|
|
840
|
+
filtersApplied.push("ransomwareOnly");
|
|
841
|
+
if (addedSince)
|
|
842
|
+
filtersApplied.push(`addedSince(${addedSince})`);
|
|
843
|
+
if (dueBefore)
|
|
844
|
+
filtersApplied.push(`dueBefore(${dueBefore})`);
|
|
845
|
+
const cveUpper = cveId ? cveId.toUpperCase() : null;
|
|
846
|
+
const matchesAll = kev.entries.filter((e) => {
|
|
847
|
+
if (cveUpper && (e.cveID ?? "").toUpperCase() !== cveUpper)
|
|
848
|
+
return false;
|
|
849
|
+
if (vendorProject && !(e.vendorProject ?? "").toLowerCase().includes(vendorProject)) {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
if (product && !(e.product ?? "").toLowerCase().includes(product))
|
|
853
|
+
return false;
|
|
854
|
+
if (ransomwareOnly && (e.knownRansomwareCampaignUse ?? "") !== "Known")
|
|
855
|
+
return false;
|
|
856
|
+
if (addedSince && (e.dateAdded === null || e.dateAdded < addedSince))
|
|
857
|
+
return false;
|
|
858
|
+
if (dueBefore && (e.dueDate === null || e.dueDate >= dueBefore))
|
|
859
|
+
return false;
|
|
860
|
+
return true;
|
|
861
|
+
});
|
|
862
|
+
const totalMatches = matchesAll.length;
|
|
863
|
+
const page = matchesAll.slice(offset, offset + limit);
|
|
864
|
+
const matches = page.map((e) => ({
|
|
865
|
+
cveID: e.cveID,
|
|
866
|
+
vendorProject: e.vendorProject,
|
|
867
|
+
product: e.product,
|
|
868
|
+
vulnerabilityName: e.vulnerabilityName,
|
|
869
|
+
dateAdded: e.dateAdded,
|
|
870
|
+
dueDate: e.dueDate,
|
|
871
|
+
knownRansomwareCampaignUse: e.knownRansomwareCampaignUse, // verbatim
|
|
872
|
+
shortDescription: e.shortDescription,
|
|
873
|
+
requiredAction: e.requiredAction,
|
|
874
|
+
cwes: e.cwes,
|
|
875
|
+
nvdUrl: e.cveID !== null ? nvdDetailUrl(e.cveID) : null,
|
|
876
|
+
}));
|
|
877
|
+
const candidateNext = offset + matches.length;
|
|
878
|
+
const nextOffset = candidateNext >= totalMatches ? null : candidateNext;
|
|
879
|
+
const hasMore = nextOffset !== null && matches.length > 0;
|
|
880
|
+
const ageHours = Math.round(((Date.now() - kev.fetchedAt) / 3_600_000) * 10) / 10;
|
|
881
|
+
const data = {
|
|
882
|
+
catalogVersion: kev.catalogVersion,
|
|
883
|
+
dateReleased: kev.dateReleased,
|
|
884
|
+
count: kev.count,
|
|
885
|
+
matches,
|
|
886
|
+
};
|
|
887
|
+
// For a cveId query, `found` reflects catalog membership of THAT CVE.
|
|
888
|
+
if (cveId)
|
|
889
|
+
data.found = totalMatches > 0;
|
|
890
|
+
// ★ M3 — the not-in-KEV ≠ safe caveat rides on EVERY response (the load-bearing
|
|
891
|
+
// hazard is a `found:false`/zero-match reading as "safe/not-exploited").
|
|
892
|
+
const notes = [
|
|
893
|
+
KEV_NOT_SAFE_NOTE,
|
|
894
|
+
`CISA KEV catalog version ${kev.catalogVersion ?? "unknown"} (released ${kev.dateReleased ?? "unknown"}), ${kev.count} entries, loaded ${ageHours}h ago (TTL 6h). dueDate is the CISA-mandated remediation deadline (BOD 22-01 / its successor BOD 26-04 — see requiredAction verbatim); knownRansomwareCampaignUse is surfaced verbatim (Known/Unknown).`,
|
|
895
|
+
];
|
|
896
|
+
if (cveId && totalMatches === 0) {
|
|
897
|
+
notes.push(`${cveId} is NOT in the CISA KEV catalog (found:false). Per the caveat above, this is NOT a determination that the CVE is unexploited or safe — KEV is a curated subset of confirmed in-the-wild exploitation.`);
|
|
898
|
+
}
|
|
899
|
+
const metaOut = {
|
|
900
|
+
source: "www.cisa.gov Known Exploited Vulnerabilities catalog (keyless)",
|
|
901
|
+
keylessMode: true,
|
|
902
|
+
returned: matches.length,
|
|
903
|
+
totalAvailable: totalMatches,
|
|
904
|
+
filtersApplied,
|
|
905
|
+
filtersDropped: [],
|
|
906
|
+
fieldsUnavailable: [],
|
|
907
|
+
pagination: { offset, limit, nextOffset, hasMore },
|
|
908
|
+
notes,
|
|
909
|
+
};
|
|
910
|
+
return withMeta(data, metaOut);
|
|
911
|
+
}
|
|
912
|
+
//# sourceMappingURL=nvd.js.map
|