@blamejs/exceptd-skills 0.19.32 → 0.19.34
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/CHANGELOG.md +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/source-osv.js
CHANGED
|
@@ -1,70 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Endpoints:
|
|
12
|
-
* GET https://api.osv.dev/v1/vulns/{id}
|
|
13
|
-
* Fetch by OSV id. CVE-* is NOT a primary key — CVE numbers live
|
|
14
|
-
* under `aliases` on records whose primary id is GHSA-*, MAL-*, etc.
|
|
15
|
-
* POST https://api.osv.dev/v1/query
|
|
16
|
-
* Body { "package": { "name": "...", "ecosystem": "..." }
|
|
17
|
-
* [,"version": "..."] }
|
|
18
|
-
* Lists vulns for a package (optionally filtered to a version).
|
|
19
|
-
*
|
|
20
|
-
* Why this matters: MAL-* (OSSF Malicious Packages) is the canonical
|
|
21
|
-
* namespace for package-compromise events that don't have a CVE yet.
|
|
22
|
-
* The elementary-data PyPI worm (MAL-2026-3083) is the catalog's
|
|
23
|
-
* reference example of that class.
|
|
24
|
-
*
|
|
25
|
-
* Returns drafts — every imported entry carries `_auto_imported: true`
|
|
26
|
-
* + `_draft: true` so the strict catalog validator treats them as
|
|
27
|
-
* warnings, not errors. Editorial fields (framework_control_gaps,
|
|
28
|
-
* atlas_refs, attack_refs, rwep_factors) remain null until a human or
|
|
29
|
-
* AI assistant fills them in via the cve-curation skill / seven-phase
|
|
30
|
-
* playbook flow.
|
|
31
|
-
*
|
|
32
|
-
* Honors EXCEPTD_OSV_FIXTURE env var for offline testing — value is a
|
|
33
|
-
* path to a JSON file containing either a single OSV record or an
|
|
34
|
-
* array of OSV records. Matches the GHSA fixture pattern.
|
|
35
|
-
*
|
|
36
|
-
* Zero npm deps. Node 24 stdlib only.
|
|
4
|
+
* OSV.dev fetcher. CVE-* is NOT a primary key there — CVE numbers live under
|
|
5
|
+
* `aliases` on records whose id is GHSA-*, MAL-*, etc.; MAL-* is the canonical
|
|
6
|
+
* namespace for a package compromise with no CVE. Every imported entry is a
|
|
7
|
+
* draft (`_auto_imported` + `_draft`), so the strict validator warns rather than
|
|
8
|
+
* errors and editorial fields stay null. EXCEPTD_OSV_FIXTURE points at a JSON
|
|
9
|
+
* file of one record or an array of them, for offline runs.
|
|
37
10
|
*/
|
|
38
11
|
|
|
39
12
|
const https = require("https");
|
|
40
13
|
const fs = require("fs");
|
|
41
14
|
const { withRetry } = require("../vendor/blamejs/retry.js");
|
|
42
15
|
|
|
43
|
-
// Production
|
|
44
|
-
// harness sets OSV_HOST_OVERRIDE, whose parsing and host/port/transport
|
|
45
|
-
// substitution are documented at osvTransport() below.
|
|
16
|
+
// Production endpoint; OSV_HOST_OVERRIDE substitutes host/port/transport — see osvTransport().
|
|
46
17
|
const OSV_HOST = "api.osv.dev";
|
|
47
18
|
const REQUEST_TIMEOUT_MS = 10000;
|
|
48
19
|
const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
|
|
49
20
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// can't stream unbounded bytes into RAM. A real /v1/vulns/{id} or /v1/query
|
|
53
|
-
// response is a few KB; 16 MB is generous headroom. Symmetric with the JSON
|
|
54
|
-
// cap getJsonOnce enforces in lib/refresh-network.js. Tunable via
|
|
55
|
-
// EXCEPTD_OSV_RESPONSE_CAP_BYTES for future API growth.
|
|
21
|
+
// Caps the streamed response so a hostile endpoint cannot stream unbounded bytes
|
|
22
|
+
// into RAM. Symmetric with the JSON cap getJsonOnce enforces in lib/refresh-network.js.
|
|
56
23
|
function osvResponseCapBytes() {
|
|
57
24
|
const env = parseInt(process.env.EXCEPTD_OSV_RESPONSE_CAP_BYTES, 10);
|
|
58
25
|
return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
|
|
59
26
|
}
|
|
60
27
|
|
|
61
|
-
// Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// coverage (cvss object, vulnerable_version_range string, ghsa_id linkage)
|
|
65
|
-
// than OSV's import of the same advisories. Keep this list in sync with the
|
|
66
|
-
// dispatcher in lib/refresh-external.js — adding a new prefix here is not
|
|
67
|
-
// enough; the dispatcher's --advisory regex must also accept it.
|
|
28
|
+
// Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is deliberately absent:
|
|
29
|
+
// seedSingleAdvisory in lib/refresh-external.js routes CVE-* and GHSA-* through
|
|
30
|
+
// `source-ghsa`. Adding a prefix here also needs that dispatcher's --advisory regex.
|
|
68
31
|
const OSV_ID_PREFIXES = [
|
|
69
32
|
"MAL-", // OSSF Malicious Packages
|
|
70
33
|
"SNYK-", // Snyk
|
|
@@ -88,32 +51,20 @@ const OSV_ID_PREFIXES = [
|
|
|
88
51
|
];
|
|
89
52
|
|
|
90
53
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* `field_dropped` diff so curators can investigate the upstream regression
|
|
94
|
-
* rather than silently losing signal. Keep this set small + intentional —
|
|
95
|
-
* fields here MUST be ones the editorial review process can re-source.
|
|
54
|
+
* Draft fields the field-dropped detector watches. Keep the set small — a field
|
|
55
|
+
* here MUST be one the editorial review process can re-source.
|
|
96
56
|
*/
|
|
97
57
|
const FIELD_DROPPED_WATCH = Object.freeze([
|
|
98
|
-
// Only fields
|
|
99
|
-
//
|
|
100
|
-
// poc_available are EDITORIAL (curated by hand, never upstream-sourced), so
|
|
101
|
-
// the normalize step always nulls them — watching them flagged a field_dropped
|
|
102
|
-
// "regression" on every curated re-import. Restrict to upstream-sourced fields.
|
|
58
|
+
// Only upstream-populated fields: normalize nulls the editorial ones on every
|
|
59
|
+
// import, so watching them flags a false regression on each re-import.
|
|
103
60
|
"cvss_score",
|
|
104
61
|
"cisa_kev_pending",
|
|
105
62
|
]);
|
|
106
63
|
|
|
107
|
-
/**
|
|
108
|
-
* Return true when `id` looks like an OSV-native primary key (i.e. NOT a
|
|
109
|
-
* CVE-* identifier and NOT a GHSA-* identifier). Both CVE-* and GHSA-*
|
|
110
|
-
* route through `source-ghsa` for richer field coverage.
|
|
111
|
-
*/
|
|
64
|
+
/** True when `id` is an OSV-native primary key; CVE-* and GHSA-* route to `source-ghsa`. */
|
|
112
65
|
function isOsvId(id) {
|
|
113
66
|
if (!id || typeof id !== "string") return false;
|
|
114
|
-
//
|
|
115
|
-
// ids from clipboards / multi-line files don't see a surprising routing
|
|
116
|
-
// miss. Empty after trim → not an OSV id.
|
|
67
|
+
// Trimmed first, so a pasted id doesn't miss the routing.
|
|
117
68
|
const trimmed = id.trim();
|
|
118
69
|
if (!trimmed) return false;
|
|
119
70
|
const up = trimmed.toUpperCase();
|
|
@@ -123,10 +74,8 @@ function isOsvId(id) {
|
|
|
123
74
|
}
|
|
124
75
|
|
|
125
76
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* a structured `air-gap: no fixture available` error so the operator sees
|
|
129
|
-
* an explicit refusal, not a silent network attempt.
|
|
77
|
+
* True when the runtime context requests air-gap mode. Sources MUST refuse the
|
|
78
|
+
* network then — a fixture, or a structured `air-gap` error; never a silent attempt.
|
|
130
79
|
*/
|
|
131
80
|
function isAirGap(opts) {
|
|
132
81
|
if (opts && opts.airGap) return true;
|
|
@@ -135,15 +84,9 @@ function isAirGap(opts) {
|
|
|
135
84
|
}
|
|
136
85
|
|
|
137
86
|
/**
|
|
138
|
-
* Resolve the OSV transport target.
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* override entirely and lands on api.osv.dev over HTTPS.
|
|
142
|
-
*
|
|
143
|
-
* Finding 13: validate the override aggressively. Garbage env values
|
|
144
|
-
* (random binary, embedded NUL, port > 65535) previously slipped through
|
|
145
|
-
* into the http.request options and produced opaque ENOTFOUND / EADDRINUSE
|
|
146
|
-
* errors far from the source. Reject with a structured error here instead.
|
|
87
|
+
* Resolve the OSV transport target. OSV_HOST_OVERRIDE switches the request to
|
|
88
|
+
* plain HTTP on the override host:port; production lands on api.osv.dev over
|
|
89
|
+
* HTTPS. The override is validated here, not left to surface as an ENOTFOUND.
|
|
147
90
|
*/
|
|
148
91
|
function osvTransport() {
|
|
149
92
|
const override = process.env.OSV_HOST_OVERRIDE;
|
|
@@ -176,11 +119,9 @@ function osvTransport() {
|
|
|
176
119
|
}
|
|
177
120
|
|
|
178
121
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* 408/425/429). The thrown errors carry `statusCode` so withRetry's default
|
|
183
|
-
* classifier recognizes them as retryable.
|
|
122
|
+
* One OSV request. THROWS on retryable conditions (429/503/5xx, 408/425, the
|
|
123
|
+
* ECONNRESET/ETIMEDOUT family) and RESOLVES a structured `{ok:false}` envelope on
|
|
124
|
+
* permanent ones. A thrown error carries `statusCode` for withRetry's classifier.
|
|
184
125
|
*/
|
|
185
126
|
function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
186
127
|
return new Promise((resolve, reject) => {
|
|
@@ -203,18 +144,13 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
|
203
144
|
const opts = { host, port, path: reqPath, method, headers, timeout: timeoutMs };
|
|
204
145
|
const req = mod.request(opts, (res) => {
|
|
205
146
|
const status = res.statusCode;
|
|
206
|
-
// 401/404 (and other 4xx aside from 408/425/429) are permanent.
|
|
207
|
-
// 429/503 + 5xx are retryable. Honor Retry-After when present.
|
|
208
147
|
const retryAfterRaw = res.headers["retry-after"];
|
|
209
148
|
if (status === 429 || status === 503 || (status >= 500 && status <= 599) ||
|
|
210
149
|
status === 408 || status === 425) {
|
|
211
150
|
res.resume();
|
|
212
151
|
const err = new Error(`OSV returned HTTP ${status}`);
|
|
213
152
|
err.statusCode = status;
|
|
214
|
-
//
|
|
215
|
-
// doesn't currently consume this directly — withRetry's backoff is
|
|
216
|
-
// its own schedule — but exposing it lets future schedulers honor
|
|
217
|
-
// server-advertised delay.
|
|
153
|
+
// Retry-After is exposed for a scheduler; withRetry runs its own backoff.
|
|
218
154
|
if (retryAfterRaw) {
|
|
219
155
|
const secs = parseInt(retryAfterRaw, 10);
|
|
220
156
|
if (Number.isFinite(secs)) err.retryAfterMs = secs * 1000;
|
|
@@ -240,8 +176,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
|
240
176
|
chunks.push(c);
|
|
241
177
|
});
|
|
242
178
|
res.on("end", () => {
|
|
243
|
-
//
|
|
244
|
-
// don't also resolve here (avoids a double-settle on the promise).
|
|
179
|
+
// The cap already routed through req.on("error"); resolving here settles twice.
|
|
245
180
|
if (capped) return;
|
|
246
181
|
try {
|
|
247
182
|
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
@@ -257,8 +192,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
|
257
192
|
req.destroy(err);
|
|
258
193
|
});
|
|
259
194
|
req.on("error", (e) => {
|
|
260
|
-
// Retryable network errors propagate
|
|
261
|
-
// resolve as structured offline.
|
|
195
|
+
// Retryable network errors propagate to withRetry; the rest resolve offline.
|
|
262
196
|
if (e && e.code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH)$/.test(e.code)) {
|
|
263
197
|
return reject(e);
|
|
264
198
|
}
|
|
@@ -269,11 +203,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
|
269
203
|
});
|
|
270
204
|
}
|
|
271
205
|
|
|
272
|
-
/**
|
|
273
|
-
* Low-level GET against OSV. Resolves to { ok, record|error, source }.
|
|
274
|
-
* Honors OSV_HOST_OVERRIDE for offline tests. Wraps the request in
|
|
275
|
-
* withRetry so 429/503/5xx + transient net errors back off automatically.
|
|
276
|
-
*/
|
|
206
|
+
/** GET against OSV → { ok, record|error, source }; withRetry backs off 429/503/5xx. */
|
|
277
207
|
async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
278
208
|
try {
|
|
279
209
|
return await withRetry(() => osvRequestOnce({ method: "GET", reqPath, timeoutMs }), {
|
|
@@ -283,8 +213,7 @@ async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
283
213
|
jitterFactor: 0.5,
|
|
284
214
|
});
|
|
285
215
|
} catch (e) {
|
|
286
|
-
//
|
|
287
|
-
// than letting the throw escape into the caller's promise chain.
|
|
216
|
+
// Once retries are exhausted, return an envelope; never let the throw escape.
|
|
288
217
|
const status = typeof e?.statusCode === "number" ? e.statusCode : null;
|
|
289
218
|
const error = status === 429
|
|
290
219
|
? `OSV rate-limited (HTTP 429)`
|
|
@@ -295,10 +224,7 @@ async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
295
224
|
}
|
|
296
225
|
}
|
|
297
226
|
|
|
298
|
-
/**
|
|
299
|
-
* Low-level POST against OSV. Body is JSON-stringified. Same retry policy
|
|
300
|
-
* as osvGet — 429/503/5xx + transient net errors back off automatically.
|
|
301
|
-
*/
|
|
227
|
+
/** POST against OSV; the body is JSON-stringified. Same retry policy as osvGet. */
|
|
302
228
|
async function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
303
229
|
try {
|
|
304
230
|
return await withRetry(() => osvRequestOnce({ method: "POST", reqPath, body, timeoutMs }), {
|
|
@@ -319,16 +245,9 @@ async function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
319
245
|
}
|
|
320
246
|
|
|
321
247
|
/**
|
|
322
|
-
* Read EXCEPTD_OSV_FIXTURE
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* rather than throw — operators on the CLI surface get a structured error
|
|
326
|
-
* instead of a Node stack trace.
|
|
327
|
-
*
|
|
328
|
-
* Returns:
|
|
329
|
-
* null when env var is unset
|
|
330
|
-
* { ok: true, advisories: [...], source } on success
|
|
331
|
-
* { ok: false, error, source: "offline" } on any failure
|
|
248
|
+
* Read EXCEPTD_OSV_FIXTURE. Null when unset, `{ ok: true, advisories, source }`
|
|
249
|
+
* on success, `{ ok: false, error, source: "offline" }` on any failure — missing
|
|
250
|
+
* file, malformed JSON, root neither object nor array. Never throws.
|
|
332
251
|
*/
|
|
333
252
|
function readFixture() {
|
|
334
253
|
const fp = process.env.EXCEPTD_OSV_FIXTURE;
|
|
@@ -352,9 +271,7 @@ function readFixture() {
|
|
|
352
271
|
}
|
|
353
272
|
|
|
354
273
|
/**
|
|
355
|
-
* Fetch
|
|
356
|
-
*
|
|
357
|
-
* Returns shape matches source-ghsa.fetchAdvisoryById:
|
|
274
|
+
* Fetch one OSV record by id. Return shape matches source-ghsa.fetchAdvisoryById:
|
|
358
275
|
* { ok: true, advisories: [<osv_record>], source: "osv-api" | "fixture" }
|
|
359
276
|
* { ok: false, error, source: "offline" | "fixture" }
|
|
360
277
|
*/
|
|
@@ -362,18 +279,15 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
362
279
|
if (!id || typeof id !== "string") {
|
|
363
280
|
return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
|
|
364
281
|
}
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// lowercase ids from grep/jq don't get a surprising 404 from the network
|
|
368
|
-
// path. Fixture lookup already case-folds, so this normalization is a
|
|
369
|
-
// no-op there but harmless.
|
|
282
|
+
// /v1/vulns/{id} is case-sensitive — `mal-2026-3083` 404s where `MAL-2026-3083`
|
|
283
|
+
// resolves — so uppercase and trim at entry.
|
|
370
284
|
id = id.trim().toUpperCase();
|
|
371
285
|
if (!id) {
|
|
372
286
|
return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
|
|
373
287
|
}
|
|
374
288
|
const fixture = readFixture();
|
|
375
289
|
if (fixture) {
|
|
376
|
-
if (!fixture.ok) return fixture;
|
|
290
|
+
if (!fixture.ok) return fixture;
|
|
377
291
|
const want = id;
|
|
378
292
|
const match = fixture.advisories.find((rec) => {
|
|
379
293
|
const recId = (rec && rec.id) ? String(rec.id).toUpperCase() : null;
|
|
@@ -384,9 +298,7 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
384
298
|
if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
|
|
385
299
|
return { ok: true, advisories: [match], source: "fixture" };
|
|
386
300
|
}
|
|
387
|
-
//
|
|
388
|
-
// `exceptd refresh --air-gap` without a fixture get a structured refusal,
|
|
389
|
-
// not an outbound DNS query.
|
|
301
|
+
// Air-gap hard-refuses: no fixture means a structured refusal, not a DNS query.
|
|
390
302
|
if (isAirGap(opts)) {
|
|
391
303
|
return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
|
|
392
304
|
}
|
|
@@ -395,21 +307,15 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
395
307
|
return { ok: true, advisories: [result.record], source: "osv-api" };
|
|
396
308
|
}
|
|
397
309
|
|
|
398
|
-
/**
|
|
399
|
-
* List advisories for a package, optionally filtered to a specific version.
|
|
400
|
-
* The single-package network path is implemented; there is no bulk /
|
|
401
|
-
* package-watchlist import caller — advisories are seeded one at a time via
|
|
402
|
-
* `refresh --advisory <id>`.
|
|
403
|
-
*/
|
|
310
|
+
/** List advisories for a package, optionally filtered to one version. */
|
|
404
311
|
async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
|
|
405
312
|
if (!name || !ecosystem) {
|
|
406
313
|
return { ok: false, error: "name and ecosystem are required", source: "offline" };
|
|
407
314
|
}
|
|
408
315
|
const fixture = readFixture();
|
|
409
316
|
if (fixture) {
|
|
410
|
-
if (!fixture.ok) return fixture;
|
|
411
|
-
// Best-effort
|
|
412
|
-
// contains the requested package + ecosystem (+ version when set).
|
|
317
|
+
if (!fixture.ok) return fixture;
|
|
318
|
+
// Best-effort: match on `affected[]` package + ecosystem, and version when given.
|
|
413
319
|
const matches = fixture.advisories.filter((rec) => {
|
|
414
320
|
const affected = Array.isArray(rec?.affected) ? rec.affected : [];
|
|
415
321
|
return affected.some((a) => {
|
|
@@ -423,7 +329,7 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
|
|
|
423
329
|
});
|
|
424
330
|
return { ok: true, advisories: matches, source: "fixture" };
|
|
425
331
|
}
|
|
426
|
-
//
|
|
332
|
+
// The air-gap refusal applies to the package-query path too.
|
|
427
333
|
if (isAirGap(opts)) {
|
|
428
334
|
return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
|
|
429
335
|
}
|
|
@@ -436,15 +342,9 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
|
|
|
436
342
|
}
|
|
437
343
|
|
|
438
344
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* Finding 14: the non-CVE branch must String-coerce + uppercase so a
|
|
444
|
-
* record with `id: 12345` (numeric) or `id: "mal-2026-3083"` (lowercase)
|
|
445
|
-
* doesn't produce a catalog key that diverges from the canonical
|
|
446
|
-
* uppercase-prefix convention. The CVE branch already upper-cases via
|
|
447
|
-
* `String(cve).toUpperCase()`.
|
|
345
|
+
* Catalog key for an OSV record: a CVE-* alias when present, preserving the
|
|
346
|
+
* CVE-keyed convention, else the OSV id. Both branches String-coerce and
|
|
347
|
+
* uppercase, so a numeric or lowercase id cannot diverge from the convention.
|
|
448
348
|
*/
|
|
449
349
|
function pickCatalogKey(rec) {
|
|
450
350
|
if (!rec || rec.id == null) return null;
|
|
@@ -455,12 +355,9 @@ function pickCatalogKey(rec) {
|
|
|
455
355
|
}
|
|
456
356
|
|
|
457
357
|
/**
|
|
458
|
-
* CVSS 3.1 base
|
|
459
|
-
*
|
|
460
|
-
* vector but no
|
|
461
|
-
* Returns null on malformed input.
|
|
462
|
-
*
|
|
463
|
-
* Reference: https://www.first.org/cvss/v3.1/specification-document
|
|
358
|
+
* CVSS 3.1 base score from a vector string, per Table 6 of the FIRST CVSS 3.1
|
|
359
|
+
* specification (https://www.first.org/cvss/v3.1/specification-document). Used
|
|
360
|
+
* when a record carries a vector but no numeric score. Null on malformed input.
|
|
464
361
|
*/
|
|
465
362
|
function cvss3BaseScore(vector) {
|
|
466
363
|
if (typeof vector !== "string") return null;
|
|
@@ -473,7 +370,6 @@ function cvss3BaseScore(vector) {
|
|
|
473
370
|
if (!k || !v) return null;
|
|
474
371
|
metrics[k] = v;
|
|
475
372
|
}
|
|
476
|
-
// Required metrics — bail if any are missing.
|
|
477
373
|
for (const k of ["AV", "AC", "PR", "UI", "S", "C", "I", "A"]) {
|
|
478
374
|
if (!metrics[k]) return null;
|
|
479
375
|
}
|
|
@@ -509,45 +405,30 @@ function cvss3BaseScore(vector) {
|
|
|
509
405
|
} else {
|
|
510
406
|
base = Math.min(1.08 * (impact + exploitability), 10);
|
|
511
407
|
}
|
|
512
|
-
// roundUp1 per CVSS 3.1 §7.1
|
|
513
|
-
//
|
|
514
|
-
// 10000 / 10) to avoid floating-point off-by-ones (e.g. 5.55 -> 5.6,
|
|
515
|
-
// not 5.5 if naive `Math.ceil(base * 10) / 10` is applied to a value
|
|
516
|
-
// that lands at 5.5499999... after IEEE 754 rounding). Finding 11.
|
|
408
|
+
// roundUp1 per CVSS 3.1 §7.1. The spec's integer formulation avoids the IEEE-754
|
|
409
|
+
// off-by-one where Math.ceil(base * 10) / 10 turns 5.5499999… into 5.5, not 5.6.
|
|
517
410
|
const rounded = Math.ceil(base * 100000) / 1000000 < 0
|
|
518
411
|
? null
|
|
519
412
|
: (Math.ceil(base * 100000) / 100000); // intermediate at 5 decimals
|
|
520
413
|
if (rounded == null) return null;
|
|
521
|
-
// Now round-up to 1 decimal from the high-precision intermediate.
|
|
522
414
|
const out = Math.ceil(rounded * 10 - 1e-9) / 10;
|
|
523
415
|
if (!Number.isFinite(out) || out < 0 || out > 10) return null;
|
|
524
416
|
return Math.round(out * 10) / 10; // strip trailing fp noise
|
|
525
417
|
}
|
|
526
418
|
|
|
527
419
|
/**
|
|
528
|
-
* Pull a numeric CVSS score + vector out of an OSV severity[] entry.
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* derivation yet), in which case fall back to the highest computable
|
|
533
|
-
* version below v4 so we don't silently lose a v3 9.8 (Finding 10).
|
|
534
|
-
* Returns null components when nothing parseable is present.
|
|
535
|
-
*
|
|
536
|
-
* Finding 19: when `s.score` is an object (some Snyk records embed
|
|
537
|
-
* `{ value: "CVSS:3.1/..." }`), accept `s.score.value` as the string
|
|
538
|
-
* source rather than silently producing null.
|
|
420
|
+
* Pull a numeric CVSS score + vector out of an OSV severity[] entry. The highest
|
|
421
|
+
* version wins UNLESS it cannot be scored — CVSS 4.0 derivation is not implemented
|
|
422
|
+
* — in which case the highest computable version below it is used, so a v3 9.8 is
|
|
423
|
+
* not silently lost. `s.score` may be a string or an object carrying `.value`.
|
|
539
424
|
*/
|
|
540
425
|
function extractCvss(rec) {
|
|
541
426
|
const sev = Array.isArray(rec?.severity) ? rec.severity : [];
|
|
542
427
|
let score = null;
|
|
543
|
-
// Collect all parseable vectors keyed by major version so we can fall
|
|
544
|
-
// back from v4 -> v3 if v4 fails to compute.
|
|
545
428
|
const vectorsByVersion = new Map(); // version (number) -> vector string
|
|
546
429
|
let bareScore = null;
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
// uncomputable v4 vector). Used to keep the HIGHEST-scoring vector per major
|
|
550
|
-
// version regardless of severity[] array order.
|
|
430
|
+
// Score for one vector: its trailing /N.N when present, else the derived CVSS 3.x
|
|
431
|
+
// base score, else null. Keeps the HIGHEST-scoring vector per major version.
|
|
551
432
|
const vectorScore = (vec) => {
|
|
552
433
|
const tail = vec.match(/\/(\d+(?:\.\d+)?)$/);
|
|
553
434
|
if (tail) { const t = parseFloat(tail[1]); if (t >= 0 && t <= 10) return t; }
|
|
@@ -559,32 +440,26 @@ function extractCvss(rec) {
|
|
|
559
440
|
let raw = null;
|
|
560
441
|
if (typeof s.score === "string") raw = s.score;
|
|
561
442
|
else if (typeof s.score === "object" && s.score && typeof s.score.value === "string") {
|
|
562
|
-
raw = s.score.value;
|
|
443
|
+
raw = s.score.value;
|
|
563
444
|
}
|
|
564
445
|
if (raw == null) continue;
|
|
565
446
|
const v = raw.trim();
|
|
566
447
|
// Bare numeric score (no vector prefix).
|
|
567
448
|
const num = parseFloat(v);
|
|
568
449
|
if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
|
|
569
|
-
// HIGHEST-wins, not first-wins:
|
|
570
|
-
//
|
|
571
|
-
// CNA/vendor score) and the upstream array order is not guaranteed. A
|
|
572
|
-
// first-wins guard silently downgraded a 9.8 critical to a 5.3 medium
|
|
573
|
-
// purely because the lower score appeared earlier in severity[].
|
|
450
|
+
// HIGHEST-wins, not first-wins: several same-version entries arrive in no
|
|
451
|
+
// guaranteed order, and first-wins downgrades a 9.8 critical to a 5.3.
|
|
574
452
|
if (bareScore == null || num > bareScore) bareScore = num;
|
|
575
453
|
continue;
|
|
576
454
|
}
|
|
577
455
|
const m = v.match(/^CVSS:(\d+\.\d+)/);
|
|
578
456
|
if (!m) continue;
|
|
579
457
|
const ver = parseFloat(m[1]);
|
|
580
|
-
//
|
|
581
|
-
// as the bare-score path above). Compare computed scores, not array order.
|
|
458
|
+
// Same within each major version: compare computed scores, not array order.
|
|
582
459
|
const prev = vectorsByVersion.get(ver);
|
|
583
460
|
if (!prev || (vectorScore(v) ?? -1) > (vectorScore(prev) ?? -1)) vectorsByVersion.set(ver, v);
|
|
584
461
|
}
|
|
585
|
-
//
|
|
586
|
-
// implemented here — if v4 was the highest but can't be computed, walk
|
|
587
|
-
// down to v3.x. Only return null when ALL versions fail.
|
|
462
|
+
// Descending; score stays null only when every version fails.
|
|
588
463
|
const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
|
|
589
464
|
let bestVector = null;
|
|
590
465
|
for (const ver of versions) {
|
|
@@ -600,13 +475,9 @@ function extractCvss(rec) {
|
|
|
600
475
|
const computed = cvss3BaseScore(candidate);
|
|
601
476
|
if (computed != null) { score = computed; break; }
|
|
602
477
|
}
|
|
603
|
-
// v4 has no in-module computer — keep walking down
|
|
604
|
-
// version. The bestVector tracker holds whatever was tried last;
|
|
605
|
-
// overwrite it with the next computable on the loop iteration.
|
|
478
|
+
// v4 has no in-module computer — keep walking down.
|
|
606
479
|
}
|
|
607
|
-
//
|
|
608
|
-
// was usable, prefer the lower-version one's vector string so callers
|
|
609
|
-
// don't get a v4 vector + null score combo when v3 was available.
|
|
480
|
+
// Prefer a lower-version vector when the highest was uncomputable, so no v4-with-null.
|
|
610
481
|
if (score == null && versions.length > 0) {
|
|
611
482
|
for (const ver of versions) {
|
|
612
483
|
if (ver >= 4) continue;
|
|
@@ -614,19 +485,12 @@ function extractCvss(rec) {
|
|
|
614
485
|
if (candidate) { bestVector = candidate; break; }
|
|
615
486
|
}
|
|
616
487
|
}
|
|
617
|
-
//
|
|
618
|
-
// score that came from a v3-only severity entry, prefer the v3 vector
|
|
619
|
-
// string when one exists. (Handles the case described in the audit:
|
|
620
|
-
// v4 is the highest version but uncomputable; a v3 vector with 9.8
|
|
621
|
-
// sits alongside. Without this fallback we lose the 9.8.)
|
|
488
|
+
// Last resort: a bare numeric score, when no vector was computable.
|
|
622
489
|
if (score == null && bareScore != null) score = bareScore;
|
|
623
490
|
return { score, vector: bestVector };
|
|
624
491
|
}
|
|
625
492
|
|
|
626
|
-
/**
|
|
627
|
-
* Coarse package-ecosystem inference for the catalog `type` field. Mirrors
|
|
628
|
-
* the same heuristic used by source-ghsa.
|
|
629
|
-
*/
|
|
493
|
+
/** Coarse package-ecosystem inference for the catalog `type`. Mirrors source-ghsa. */
|
|
630
494
|
function inferType(rec) {
|
|
631
495
|
const ecos = new Set();
|
|
632
496
|
const affected = Array.isArray(rec?.affected) ? rec.affected : [];
|
|
@@ -645,11 +509,8 @@ function inferType(rec) {
|
|
|
645
509
|
}
|
|
646
510
|
|
|
647
511
|
/**
|
|
648
|
-
* Validate
|
|
649
|
-
*
|
|
650
|
-
* null instead of throwing on .slice().
|
|
651
|
-
* - ISO-prefix regex + year sanity bound so garbage like "yesterday" or
|
|
652
|
-
* "0001-01-01" doesn't pollute downstream surfaces.
|
|
512
|
+
* Validate and slice a published/modified timestamp; a non-string, a non-ISO
|
|
513
|
+
* prefix or a year outside [1990, next year] all yield null rather than throwing.
|
|
653
514
|
*/
|
|
654
515
|
function safeDateSlice(value) {
|
|
655
516
|
if (typeof value !== "string") return null;
|
|
@@ -662,14 +523,10 @@ function safeDateSlice(value) {
|
|
|
662
523
|
}
|
|
663
524
|
|
|
664
525
|
/**
|
|
665
|
-
* Normalize an OSV record into the
|
|
666
|
-
* `{ [catalogKey]: <draft-entry> }` so
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* Editorial fields (framework_control_gaps, atlas_refs, attack_refs,
|
|
670
|
-
* rwep_factors) are left null — the seven-phase playbook flow or a human
|
|
671
|
-
* reviewer fills these in. `_auto_imported: true` + `_draft: true` flags
|
|
672
|
-
* mark the entry for the strict catalog validator (warn, not error).
|
|
526
|
+
* Normalize an OSV record into the catalog draft shape, as
|
|
527
|
+
* `{ [catalogKey]: <draft-entry> }` so a caller can spread it into the catalog.
|
|
528
|
+
* Null when the record is unusable. Editorial fields are left null, and
|
|
529
|
+
* `_auto_imported` + `_draft` mark the entry warn-not-error for the validator.
|
|
673
530
|
*/
|
|
674
531
|
function normalizeAdvisory(rec) {
|
|
675
532
|
if (!rec || rec.id == null) return null;
|
|
@@ -680,15 +537,13 @@ function normalizeAdvisory(rec) {
|
|
|
680
537
|
if (!catalogKey) return null;
|
|
681
538
|
|
|
682
539
|
const aliases = Array.isArray(rec.aliases) ? rec.aliases.slice() : [];
|
|
683
|
-
//
|
|
684
|
-
// the aliases array so it stays discoverable.
|
|
540
|
+
// When the key came from aliases (CVE-*), the OSV id goes back into aliases.
|
|
685
541
|
if (catalogKey !== rec.id && !aliases.includes(rec.id)) aliases.push(rec.id);
|
|
686
542
|
|
|
687
543
|
const { score, vector } = extractCvss(rec);
|
|
688
544
|
|
|
689
545
|
const affectedPackages = [];
|
|
690
546
|
const affectedVersions = [];
|
|
691
|
-
// Finding 3: rec.affected might not be an array — guard before iterating.
|
|
692
547
|
const affectedList = Array.isArray(rec.affected) ? rec.affected : [];
|
|
693
548
|
for (const a of affectedList) {
|
|
694
549
|
const pkg = a?.package || {};
|
|
@@ -699,13 +554,9 @@ function normalizeAdvisory(rec) {
|
|
|
699
554
|
for (const v of versions) {
|
|
700
555
|
affectedVersions.push(`${pkg.name || "?"} == ${v}`);
|
|
701
556
|
}
|
|
702
|
-
//
|
|
703
|
-
// introduced
|
|
704
|
-
//
|
|
705
|
-
// emitted one range, losing re-introduction cycles (an introduced ->
|
|
706
|
-
// fixed -> introduced -> fixed sequence collapsed to one range).
|
|
707
|
-
// Sequential pairing produces ONE entry per (introduced, fixed |
|
|
708
|
-
// last-known-vulnerable) pair instead.
|
|
557
|
+
// Events are walked sequentially, one entry per (introduced, fixed |
|
|
558
|
+
// last-known-vulnerable) pair. Pairing the FIRST introduced with the FIRST fixed
|
|
559
|
+
// collapses an introduced → fixed → introduced cycle and loses the re-introduction.
|
|
709
560
|
const ranges = Array.isArray(a.ranges) ? a.ranges : [];
|
|
710
561
|
for (const r of ranges) {
|
|
711
562
|
const events = Array.isArray(r.events) ? r.events : [];
|
|
@@ -734,8 +585,7 @@ function normalizeAdvisory(rec) {
|
|
|
734
585
|
lastKnownVulnerable = e.last_affected;
|
|
735
586
|
}
|
|
736
587
|
}
|
|
737
|
-
// Trailing open range — no `fixed` ever observed.
|
|
738
|
-
// `>= introduced` (optionally with last_known_vulnerable upper).
|
|
588
|
+
// Trailing open range — no `fixed` was ever observed.
|
|
739
589
|
if (openIntro != null) {
|
|
740
590
|
const upper = lastKnownVulnerable ? `, <= ${lastKnownVulnerable}` : "";
|
|
741
591
|
affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}${upper}`);
|
|
@@ -743,9 +593,8 @@ function normalizeAdvisory(rec) {
|
|
|
743
593
|
}
|
|
744
594
|
}
|
|
745
595
|
|
|
746
|
-
// IoC seeding from database_specific.iocs
|
|
747
|
-
//
|
|
748
|
-
// an operator scanning a repo has something to grep for immediately.
|
|
596
|
+
// IoC seeding from database_specific.iocs; domains and URLs land in c2_indicators
|
|
597
|
+
// so an operator has something to grep for immediately.
|
|
749
598
|
const dsIocs = rec?.database_specific?.iocs || null;
|
|
750
599
|
let iocs = null;
|
|
751
600
|
if (dsIocs && (Array.isArray(dsIocs.domains) || Array.isArray(dsIocs.urls))) {
|
|
@@ -755,15 +604,13 @@ function normalizeAdvisory(rec) {
|
|
|
755
604
|
iocs = { c2_indicators: c2 };
|
|
756
605
|
}
|
|
757
606
|
|
|
758
|
-
//
|
|
759
|
-
// Finding 20: guard non-array references silently truncating to [].
|
|
607
|
+
// OSV `references` is `[{ type, url }, ...]`.
|
|
760
608
|
const refUrls = [];
|
|
761
609
|
const refList = Array.isArray(rec.references) ? rec.references : [];
|
|
762
610
|
for (const r of refList) {
|
|
763
611
|
if (r && typeof r.url === "string") refUrls.push(r.url);
|
|
764
612
|
}
|
|
765
613
|
|
|
766
|
-
// Severity wording from CVSS / qualitative hint.
|
|
767
614
|
const severityWord = score != null && score >= 9.0 ? "critical"
|
|
768
615
|
: score != null && score >= 7.0 ? "high"
|
|
769
616
|
: score != null && score >= 4.0 ? "medium"
|
|
@@ -773,25 +620,19 @@ function normalizeAdvisory(rec) {
|
|
|
773
620
|
const pending = severityWord === "critical" || (score != null && score >= 9.0);
|
|
774
621
|
|
|
775
622
|
const today = new Date().toISOString().slice(0, 10);
|
|
776
|
-
// Finding 2 + 17: type-safe + format-validated date slicing.
|
|
777
623
|
const published = safeDateSlice(rec.published);
|
|
778
624
|
const modified = safeDateSlice(rec.modified);
|
|
779
625
|
|
|
780
|
-
//
|
|
626
|
+
// The canonical osv.dev URL, used as the primary vendor advisory.
|
|
781
627
|
const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
|
|
782
628
|
|
|
783
|
-
//
|
|
784
|
-
// canonical osv.dev URL in references[] as well, which would otherwise
|
|
785
|
-
// produce a duplicate alongside the prepended `osvUrl`.
|
|
629
|
+
// Deduped: references[] often carries the canonical osv.dev URL as well.
|
|
786
630
|
const verification_sources = Array.from(new Set([
|
|
787
631
|
osvUrl,
|
|
788
632
|
...(/^CVE-/i.test(catalogKey) ? [`https://nvd.nist.gov/vuln/detail/${catalogKey}`] : []),
|
|
789
633
|
...refUrls.slice(0, 10),
|
|
790
634
|
]));
|
|
791
635
|
|
|
792
|
-
// EPSS coverage does not extend to non-CVE identifiers. Surface this
|
|
793
|
-
// explicitly so curators know to re-query if MITRE later assigns a CVE
|
|
794
|
-
// id to the entry.
|
|
795
636
|
const isCveKey = /^CVE-/i.test(catalogKey);
|
|
796
637
|
const epss_note = isCveKey
|
|
797
638
|
? null
|
|
@@ -853,9 +694,8 @@ function normalizeAdvisory(rec) {
|
|
|
853
694
|
_draft_reason: "Imported from OSV.dev on " + today + ". Editorial fields (framework_control_gaps, atlas_refs, attack_refs, iocs, vector, complexity, rwep_factors) require human review. Run `exceptd run sbom --evidence -` against an affected repo to gather IoCs; consult MITRE ATLAS + ATT&CK catalogs for refs.",
|
|
854
695
|
_source_osv_id: rec.id,
|
|
855
696
|
_source_published_at: rec.published || null,
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
// can flag a withdrawn advisory instead of importing it as if live.
|
|
697
|
+
// A retracted record carries a top-level `withdrawn` timestamp; surfaced as
|
|
698
|
+
// structured status so a citation check flags it instead of importing it live.
|
|
859
699
|
...(rec.withdrawn ? { status: "withdrawn", status_source: "osv:withdrawn", status_verified: today } : {}),
|
|
860
700
|
last_updated: modified || today,
|
|
861
701
|
},
|
|
@@ -863,15 +703,12 @@ function normalizeAdvisory(rec) {
|
|
|
863
703
|
}
|
|
864
704
|
|
|
865
705
|
/**
|
|
866
|
-
* Build a refresh diff for the refresh-external orchestrator.
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
* field has dropped from populated -> null, emit a `field_dropped` diff
|
|
871
|
-
* so curators see the upstream regression instead of silently absorbing it.
|
|
706
|
+
* Build a refresh diff for the refresh-external orchestrator. With `ctx.osv_ids`
|
|
707
|
+
* populated, each id is fetched and emitted as a `_new_entry` diff unless its key
|
|
708
|
+
* is already in the local catalog — in which case a watched field that dropped to
|
|
709
|
+
* null emits a `field_dropped` diff instead.
|
|
872
710
|
*/
|
|
873
711
|
async function buildDiff(ctx) {
|
|
874
|
-
// Finding 8: trim ids defensively at the entry seam.
|
|
875
712
|
const rawIds = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
|
|
876
713
|
const ids = rawIds.map((x) => (typeof x === "string" ? x.trim() : "")).filter(Boolean);
|
|
877
714
|
if (ids.length === 0) {
|
|
@@ -887,16 +724,12 @@ async function buildDiff(ctx) {
|
|
|
887
724
|
const cveCatalog = ctx.cveCatalog || {};
|
|
888
725
|
const existingKeys = new Set(Object.keys(cveCatalog));
|
|
889
726
|
const diffs = [];
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
// Operators triaging a refresh-report want to know whether to chase a
|
|
893
|
-
// network outage or a malformed upstream record.
|
|
727
|
+
// Unreachable (fetch failed) and normalize-rejected (fetched, normalized to null)
|
|
728
|
+
// are counted apart, so triage knows a network outage from a malformed record.
|
|
894
729
|
let unreachable = 0;
|
|
895
730
|
let normalizeErrors = 0;
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
// dropping work. Particularly useful when a curator dispatches the same
|
|
899
|
-
// batch twice and wonders why nothing happened.
|
|
731
|
+
// An id already in the catalog is skipped, not an error; counting it keeps a
|
|
732
|
+
// re-dispatched batch from reading as silently dropped work.
|
|
900
733
|
let ghsaOnlySkipped = 0;
|
|
901
734
|
for (const id of ids) {
|
|
902
735
|
const r = await fetchAdvisoryById(id, { airGap: ctx.airGap });
|
|
@@ -907,9 +740,6 @@ async function buildDiff(ctx) {
|
|
|
907
740
|
if (!normalized) { normalizeErrors++; continue; }
|
|
908
741
|
const key = Object.keys(normalized)[0];
|
|
909
742
|
if (existingKeys.has(key)) {
|
|
910
|
-
// Finding 9: field-dropped detection. Compare watched fields between
|
|
911
|
-
// the existing local entry and the freshly-normalized one. Emit a
|
|
912
|
-
// `field_dropped` diff per regression rather than a `_new_entry`.
|
|
913
743
|
const before = cveCatalog[key] || {};
|
|
914
744
|
const after = normalized[key];
|
|
915
745
|
let dropped = false;
|
|
@@ -944,9 +774,7 @@ async function buildDiff(ctx) {
|
|
|
944
774
|
});
|
|
945
775
|
}
|
|
946
776
|
const errors = unreachable + normalizeErrors;
|
|
947
|
-
// diffs holds
|
|
948
|
-
// separately so the summary doesn't mislabel field-dropped regressions as
|
|
949
|
-
// brand-new entries.
|
|
777
|
+
// diffs holds both kinds; count separately so a field_dropped is not a new entry.
|
|
950
778
|
const newCount = diffs.filter((d) => d.field === "_new_entry").length;
|
|
951
779
|
const droppedCount = diffs.filter((d) => d.variant === "field_dropped").length;
|
|
952
780
|
const summary = `OSV fetched ${ids.length} id(s); ${newCount} new entry diff(s), ${droppedCount} field-dropped regression(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected, ${ghsaOnlySkipped} ghsa_only_skipped.`;
|