@blamejs/exceptd-skills 0.12.13 → 0.12.15
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 +150 -0
- package/bin/exceptd.js +147 -9
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +18 -5
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +193 -17
- package/lib/scoring.js +20 -7
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/lib/source-osv.js
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
const https = require("https");
|
|
40
40
|
const fs = require("fs");
|
|
41
|
+
const { withRetry } = require("../vendor/blamejs/retry.js");
|
|
41
42
|
|
|
42
43
|
// OSV_HOST_OVERRIDE lets tests redirect the network call to a local HTTP
|
|
43
44
|
// server bound on 127.0.0.1:<port>. The override accepts either a bare
|
|
@@ -77,6 +78,21 @@ const OSV_ID_PREFIXES = [
|
|
|
77
78
|
"OPENSUSE-", // openSUSE
|
|
78
79
|
];
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Set of OSV draft fields the field-dropped detector watches. When a
|
|
83
|
+
* previously-populated value goes to null on a refresh, surface it as a
|
|
84
|
+
* `field_dropped` diff so curators can investigate the upstream regression
|
|
85
|
+
* rather than silently losing signal. Keep this set small + intentional —
|
|
86
|
+
* fields here MUST be ones the editorial review process can re-source.
|
|
87
|
+
*/
|
|
88
|
+
const FIELD_DROPPED_WATCH = Object.freeze([
|
|
89
|
+
"cvss_score",
|
|
90
|
+
"cisa_kev_pending",
|
|
91
|
+
"active_exploitation",
|
|
92
|
+
"ai_discovered",
|
|
93
|
+
"poc_available",
|
|
94
|
+
]);
|
|
95
|
+
|
|
80
96
|
/**
|
|
81
97
|
* Return true when `id` looks like an OSV-native primary key (i.e. NOT a
|
|
82
98
|
* CVE-* identifier and NOT a GHSA-* identifier). Both CVE-* and GHSA-*
|
|
@@ -84,98 +100,119 @@ const OSV_ID_PREFIXES = [
|
|
|
84
100
|
*/
|
|
85
101
|
function isOsvId(id) {
|
|
86
102
|
if (!id || typeof id !== "string") return false;
|
|
87
|
-
|
|
103
|
+
// F8 (finding 8): trim trailing/leading whitespace so operators pasting
|
|
104
|
+
// ids from clipboards / multi-line files don't see a surprising routing
|
|
105
|
+
// miss. Empty after trim → not an OSV id.
|
|
106
|
+
const trimmed = id.trim();
|
|
107
|
+
if (!trimmed) return false;
|
|
108
|
+
const up = trimmed.toUpperCase();
|
|
88
109
|
if (/^CVE-\d{4}-\d+$/.test(up)) return false;
|
|
89
110
|
if (up.startsWith("GHSA-")) return false;
|
|
90
111
|
return OSV_ID_PREFIXES.some((p) => up.startsWith(p));
|
|
91
112
|
}
|
|
92
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Return true when the runtime context requests air-gap mode. Sources MUST
|
|
116
|
+
* refuse network calls when this is set — fall through to fixture or return
|
|
117
|
+
* a structured `air-gap: no fixture available` error so the operator sees
|
|
118
|
+
* an explicit refusal, not a silent network attempt.
|
|
119
|
+
*/
|
|
120
|
+
function isAirGap(opts) {
|
|
121
|
+
if (opts && opts.airGap) return true;
|
|
122
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
/**
|
|
94
127
|
* Resolve the OSV transport target. When OSV_HOST_OVERRIDE is set the
|
|
95
128
|
* request switches to plain HTTP on the override host:port so test
|
|
96
129
|
* harnesses can stand up a local server without TLS. Production omits the
|
|
97
130
|
* override entirely and lands on api.osv.dev over HTTPS.
|
|
131
|
+
*
|
|
132
|
+
* Finding 13: validate the override aggressively. Garbage env values
|
|
133
|
+
* (random binary, embedded NUL, port > 65535) previously slipped through
|
|
134
|
+
* into the http.request options and produced opaque ENOTFOUND / EADDRINUSE
|
|
135
|
+
* errors far from the source. Reject with a structured error here instead.
|
|
98
136
|
*/
|
|
99
137
|
function osvTransport() {
|
|
100
138
|
const override = process.env.OSV_HOST_OVERRIDE;
|
|
101
139
|
if (!override) return { mod: https, host: OSV_HOST, port: 443 };
|
|
102
|
-
|
|
103
|
-
|
|
140
|
+
const raw = String(override).trim();
|
|
141
|
+
const HOST_RE = /^[a-z0-9.-]+$/i;
|
|
142
|
+
let host;
|
|
143
|
+
let port;
|
|
104
144
|
if (/^https?:\/\//i.test(raw)) {
|
|
105
|
-
|
|
106
|
-
|
|
145
|
+
let u;
|
|
146
|
+
try { u = new URL(raw); }
|
|
147
|
+
catch (e) {
|
|
148
|
+
return { error: `OSV_HOST_OVERRIDE: invalid URL: ${e.message}` };
|
|
149
|
+
}
|
|
150
|
+
host = u.hostname;
|
|
151
|
+
port = parseInt(u.port, 10);
|
|
152
|
+
if (!port) port = u.protocol === "https:" ? 443 : 80;
|
|
153
|
+
} else {
|
|
154
|
+
const [h, p] = raw.split(":");
|
|
155
|
+
host = h;
|
|
156
|
+
port = parseInt(p, 10) || 80;
|
|
107
157
|
}
|
|
108
|
-
|
|
109
|
-
|
|
158
|
+
if (!host || !HOST_RE.test(host)) {
|
|
159
|
+
return { error: `OSV_HOST_OVERRIDE: invalid host '${host || ""}'; must match /^[a-z0-9.-]+$/i` };
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
162
|
+
return { error: `OSV_HOST_OVERRIDE: invalid port '${port}'; must be 1..65535` };
|
|
163
|
+
}
|
|
164
|
+
return { mod: require("http"), host, port };
|
|
110
165
|
}
|
|
111
166
|
|
|
112
167
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
168
|
+
* Make one OSV request (HEAD/GET/POST). Throws on retryable conditions
|
|
169
|
+
* (HTTP 429/503/5xx + ECONNRESET/ETIMEDOUT family) and resolves to a
|
|
170
|
+
* structured `{ok:false}` envelope on permanent conditions (4xx other than
|
|
171
|
+
* 408/425/429). The thrown errors carry `statusCode` so withRetry's default
|
|
172
|
+
* classifier recognizes them as retryable.
|
|
115
173
|
*/
|
|
116
|
-
function
|
|
117
|
-
return new Promise((resolve) => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
174
|
+
function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const t = osvTransport();
|
|
177
|
+
if (t.error) {
|
|
178
|
+
// F13: surface the validation error structurally; no retry.
|
|
179
|
+
return resolve({ ok: false, error: t.error, source: "offline" });
|
|
180
|
+
}
|
|
181
|
+
const { mod, host, port } = t;
|
|
182
|
+
const headers = {
|
|
183
|
+
"Accept": "application/json",
|
|
184
|
+
"User-Agent": USER_AGENT,
|
|
185
|
+
};
|
|
186
|
+
let payload = null;
|
|
187
|
+
if (method === "POST" && body) {
|
|
188
|
+
payload = Buffer.from(JSON.stringify(body), "utf8");
|
|
189
|
+
headers["Content-Type"] = "application/json";
|
|
190
|
+
headers["Content-Length"] = payload.length;
|
|
191
|
+
}
|
|
192
|
+
const opts = { host, port, path: reqPath, method, headers, timeout: timeoutMs };
|
|
193
|
+
const req = mod.request(opts, (res) => {
|
|
194
|
+
const status = res.statusCode;
|
|
195
|
+
// 401/404 (and other 4xx aside from 408/425/429) are permanent.
|
|
196
|
+
// 429/503 + 5xx are retryable. Honor Retry-After when present.
|
|
197
|
+
const retryAfterRaw = res.headers["retry-after"];
|
|
198
|
+
if (status === 429 || status === 503 || (status >= 500 && status <= 599) ||
|
|
199
|
+
status === 408 || status === 425) {
|
|
130
200
|
res.resume();
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
142
|
-
resolve({ ok: true, record: body, source: "osv-api" });
|
|
143
|
-
} catch (e) {
|
|
144
|
-
resolve({ ok: false, error: `parse: ${e.message}`, source: "offline" });
|
|
201
|
+
const err = new Error(`OSV returned HTTP ${status}`);
|
|
202
|
+
err.statusCode = status;
|
|
203
|
+
// Surface Retry-After (seconds or HTTP-date). The retry caller
|
|
204
|
+
// doesn't currently consume this directly — withRetry's backoff is
|
|
205
|
+
// its own schedule — but exposing it lets future schedulers honor
|
|
206
|
+
// server-advertised delay.
|
|
207
|
+
if (retryAfterRaw) {
|
|
208
|
+
const secs = parseInt(retryAfterRaw, 10);
|
|
209
|
+
if (Number.isFinite(secs)) err.retryAfterMs = secs * 1000;
|
|
145
210
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Low-level POST against OSV. Body is JSON-stringified.
|
|
155
|
-
*/
|
|
156
|
-
function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
157
|
-
return new Promise((resolve) => {
|
|
158
|
-
const payload = Buffer.from(JSON.stringify(body), "utf8");
|
|
159
|
-
const { mod, host, port } = osvTransport();
|
|
160
|
-
const req = mod.request({
|
|
161
|
-
host,
|
|
162
|
-
port,
|
|
163
|
-
path: reqPath,
|
|
164
|
-
method: "POST",
|
|
165
|
-
headers: {
|
|
166
|
-
"Content-Type": "application/json",
|
|
167
|
-
"Content-Length": payload.length,
|
|
168
|
-
"Accept": "application/json",
|
|
169
|
-
"User-Agent": USER_AGENT,
|
|
170
|
-
},
|
|
171
|
-
timeout: timeoutMs,
|
|
172
|
-
}, (res) => {
|
|
173
|
-
if (res.statusCode !== 200) {
|
|
211
|
+
return reject(err);
|
|
212
|
+
}
|
|
213
|
+
if (status !== 200) {
|
|
174
214
|
res.resume();
|
|
175
|
-
const
|
|
176
|
-
const error = status === 429
|
|
177
|
-
? `OSV rate-limited (HTTP 429)`
|
|
178
|
-
: `OSV returned HTTP ${status}`;
|
|
215
|
+
const error = `OSV returned HTTP ${status}`;
|
|
179
216
|
return resolve({ ok: false, error, status, source: "offline" });
|
|
180
217
|
}
|
|
181
218
|
const chunks = [];
|
|
@@ -189,13 +226,73 @@ function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
189
226
|
}
|
|
190
227
|
});
|
|
191
228
|
});
|
|
192
|
-
req.on("timeout", () =>
|
|
193
|
-
|
|
194
|
-
|
|
229
|
+
req.on("timeout", () => {
|
|
230
|
+
const err = new Error("OSV request timed out");
|
|
231
|
+
err.code = "ETIMEDOUT";
|
|
232
|
+
req.destroy(err);
|
|
233
|
+
});
|
|
234
|
+
req.on("error", (e) => {
|
|
235
|
+
// Retryable network errors propagate up to withRetry; non-retryable
|
|
236
|
+
// resolve as structured offline.
|
|
237
|
+
if (e && e.code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH)$/.test(e.code)) {
|
|
238
|
+
return reject(e);
|
|
239
|
+
}
|
|
240
|
+
resolve({ ok: false, error: e.message, source: "offline" });
|
|
241
|
+
});
|
|
242
|
+
if (payload) req.write(payload);
|
|
195
243
|
req.end();
|
|
196
244
|
});
|
|
197
245
|
}
|
|
198
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Low-level GET against OSV. Resolves to { ok, record|error, source }.
|
|
249
|
+
* Honors OSV_HOST_OVERRIDE for offline tests. Wraps the request in
|
|
250
|
+
* withRetry so 429/503/5xx + transient net errors back off automatically.
|
|
251
|
+
*/
|
|
252
|
+
async function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
253
|
+
try {
|
|
254
|
+
return await withRetry(() => osvRequestOnce({ method: "GET", reqPath, timeoutMs }), {
|
|
255
|
+
maxAttempts: 3,
|
|
256
|
+
baseDelayMs: 100,
|
|
257
|
+
maxDelayMs: 2000,
|
|
258
|
+
jitterFactor: 0.5,
|
|
259
|
+
});
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// After exhaustion of retries, return a structured envelope rather
|
|
262
|
+
// than letting the throw escape into the caller's promise chain.
|
|
263
|
+
const status = typeof e?.statusCode === "number" ? e.statusCode : null;
|
|
264
|
+
const error = status === 429
|
|
265
|
+
? `OSV rate-limited (HTTP 429)`
|
|
266
|
+
: status
|
|
267
|
+
? `OSV returned HTTP ${status}`
|
|
268
|
+
: `OSV request failed: ${e.message || e}`;
|
|
269
|
+
return { ok: false, error, status, source: "offline" };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Low-level POST against OSV. Body is JSON-stringified. Same retry policy
|
|
275
|
+
* as osvGet — 429/503/5xx + transient net errors back off automatically.
|
|
276
|
+
*/
|
|
277
|
+
async function osvPost(reqPath, body, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
278
|
+
try {
|
|
279
|
+
return await withRetry(() => osvRequestOnce({ method: "POST", reqPath, body, timeoutMs }), {
|
|
280
|
+
maxAttempts: 3,
|
|
281
|
+
baseDelayMs: 100,
|
|
282
|
+
maxDelayMs: 2000,
|
|
283
|
+
jitterFactor: 0.5,
|
|
284
|
+
});
|
|
285
|
+
} catch (e) {
|
|
286
|
+
const status = typeof e?.statusCode === "number" ? e.statusCode : null;
|
|
287
|
+
const error = status === 429
|
|
288
|
+
? `OSV rate-limited (HTTP 429)`
|
|
289
|
+
: status
|
|
290
|
+
? `OSV returned HTTP ${status}`
|
|
291
|
+
: `OSV request failed: ${e.message || e}`;
|
|
292
|
+
return { ok: false, error, status, source: "offline" };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
199
296
|
/**
|
|
200
297
|
* Read EXCEPTD_OSV_FIXTURE and return a structured envelope. Matches the
|
|
201
298
|
* GHSA-source convention: on any failure (missing file, malformed JSON,
|
|
@@ -241,11 +338,14 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
241
338
|
return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
|
|
242
339
|
}
|
|
243
340
|
// OSV.dev's /v1/vulns/{id} is case-sensitive — `mal-2026-3083` 404s while
|
|
244
|
-
// `MAL-2026-3083` resolves. Uppercase at entry so operators piping
|
|
341
|
+
// `MAL-2026-3083` resolves. Uppercase + trim at entry so operators piping
|
|
245
342
|
// lowercase ids from grep/jq don't get a surprising 404 from the network
|
|
246
343
|
// path. Fixture lookup already case-folds, so this normalization is a
|
|
247
344
|
// no-op there but harmless.
|
|
248
|
-
id = id.toUpperCase();
|
|
345
|
+
id = id.trim().toUpperCase();
|
|
346
|
+
if (!id) {
|
|
347
|
+
return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
|
|
348
|
+
}
|
|
249
349
|
const fixture = readFixture();
|
|
250
350
|
if (fixture) {
|
|
251
351
|
if (!fixture.ok) return fixture; // F1: structured error envelope
|
|
@@ -259,6 +359,12 @@ async function fetchAdvisoryById(id, opts = {}) {
|
|
|
259
359
|
if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
|
|
260
360
|
return { ok: true, advisories: [match], source: "fixture" };
|
|
261
361
|
}
|
|
362
|
+
// Finding 7: air-gap mode hard-refuses network calls. Operators running
|
|
363
|
+
// `exceptd refresh --air-gap` without a fixture get a structured refusal,
|
|
364
|
+
// not an outbound DNS query.
|
|
365
|
+
if (isAirGap(opts)) {
|
|
366
|
+
return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
|
|
367
|
+
}
|
|
262
368
|
const result = await osvGet(`/v1/vulns/${encodeURIComponent(id)}`, opts.timeoutMs);
|
|
263
369
|
if (!result.ok) return result;
|
|
264
370
|
return { ok: true, advisories: [result.record], source: "osv-api" };
|
|
@@ -290,6 +396,10 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
|
|
|
290
396
|
});
|
|
291
397
|
return { ok: true, advisories: matches, source: "fixture" };
|
|
292
398
|
}
|
|
399
|
+
// Finding 7: air-gap refusal applies to the package query path too.
|
|
400
|
+
if (isAirGap(opts)) {
|
|
401
|
+
return { ok: false, error: "air-gap: no fixture available (set EXCEPTD_OSV_FIXTURE)", source: "offline" };
|
|
402
|
+
}
|
|
293
403
|
const body = { package: { name, ecosystem } };
|
|
294
404
|
if (version) body.version = version;
|
|
295
405
|
const r = await osvPost("/v1/query", body, opts.timeoutMs);
|
|
@@ -302,12 +412,19 @@ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
|
|
|
302
412
|
* Pick the catalog key for an OSV record. If `aliases` contains a CVE-*
|
|
303
413
|
* value, prefer that (preserving the existing CVE-keyed convention).
|
|
304
414
|
* Otherwise return the OSV id verbatim — MAL-*, SNYK-*, RUSTSEC-*, etc.
|
|
415
|
+
*
|
|
416
|
+
* Finding 14: the non-CVE branch must String-coerce + uppercase so a
|
|
417
|
+
* record with `id: 12345` (numeric) or `id: "mal-2026-3083"` (lowercase)
|
|
418
|
+
* doesn't produce a catalog key that diverges from the canonical
|
|
419
|
+
* uppercase-prefix convention. The CVE branch already upper-cases via
|
|
420
|
+
* `String(cve).toUpperCase()`.
|
|
305
421
|
*/
|
|
306
422
|
function pickCatalogKey(rec) {
|
|
307
|
-
if (!rec ||
|
|
423
|
+
if (!rec || rec.id == null) return null;
|
|
308
424
|
const aliases = Array.isArray(rec.aliases) ? rec.aliases : [];
|
|
309
425
|
const cve = aliases.find((a) => /^CVE-\d{4}-\d+$/i.test(String(a)));
|
|
310
|
-
|
|
426
|
+
if (cve) return String(cve).toUpperCase();
|
|
427
|
+
return String(rec.id).toUpperCase();
|
|
311
428
|
}
|
|
312
429
|
|
|
313
430
|
/**
|
|
@@ -365,57 +482,101 @@ function cvss3BaseScore(vector) {
|
|
|
365
482
|
} else {
|
|
366
483
|
base = Math.min(1.08 * (impact + exploitability), 10);
|
|
367
484
|
}
|
|
368
|
-
// roundUp1: round up to one decimal
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
485
|
+
// roundUp1 per CVSS 3.1 §7.1: round up to one decimal place. The spec
|
|
486
|
+
// uses an integer-arithmetic formulation (Math.ceil(input * 100000) /
|
|
487
|
+
// 10000 / 10) to avoid floating-point off-by-ones (e.g. 5.55 -> 5.6,
|
|
488
|
+
// not 5.5 if naive `Math.ceil(base * 10) / 10` is applied to a value
|
|
489
|
+
// that lands at 5.5499999... after IEEE 754 rounding). Finding 11.
|
|
490
|
+
const rounded = Math.ceil(base * 100000) / 1000000 < 0
|
|
491
|
+
? null
|
|
492
|
+
: (Math.ceil(base * 100000) / 100000); // intermediate at 5 decimals
|
|
493
|
+
if (rounded == null) return null;
|
|
494
|
+
// Now round-up to 1 decimal from the high-precision intermediate.
|
|
495
|
+
const out = Math.ceil(rounded * 10 - 1e-9) / 10;
|
|
496
|
+
if (!Number.isFinite(out) || out < 0 || out > 10) return null;
|
|
497
|
+
return Math.round(out * 10) / 10; // strip trailing fp noise
|
|
372
498
|
}
|
|
373
499
|
|
|
374
500
|
/**
|
|
375
501
|
* Pull a numeric CVSS score + vector out of an OSV severity[] entry. CVSS
|
|
376
502
|
* vectors start with "CVSS:3.x/" or "CVSS:4.0/". When multiple vectors are
|
|
377
|
-
* present (e.g. both V3 and V4), the highest version wins
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
503
|
+
* present (e.g. both V3 and V4), the highest version wins — UNLESS the v4
|
|
504
|
+
* vector cannot be scored (this module does not implement CVSS 4.0
|
|
505
|
+
* derivation yet), in which case fall back to the highest computable
|
|
506
|
+
* version below v4 so we don't silently lose a v3 9.8 (Finding 10).
|
|
507
|
+
* Returns null components when nothing parseable is present.
|
|
508
|
+
*
|
|
509
|
+
* Finding 19: when `s.score` is an object (some Snyk records embed
|
|
510
|
+
* `{ value: "CVSS:3.1/..." }`), accept `s.score.value` as the string
|
|
511
|
+
* source rather than silently producing null.
|
|
381
512
|
*/
|
|
382
513
|
function extractCvss(rec) {
|
|
383
514
|
const sev = Array.isArray(rec?.severity) ? rec.severity : [];
|
|
384
515
|
let score = null;
|
|
385
|
-
|
|
386
|
-
|
|
516
|
+
// Collect all parseable vectors keyed by major version so we can fall
|
|
517
|
+
// back from v4 -> v3 if v4 fails to compute.
|
|
518
|
+
const vectorsByVersion = new Map(); // version (number) -> vector string
|
|
519
|
+
let bareScore = null;
|
|
387
520
|
for (const s of sev) {
|
|
388
|
-
if (
|
|
389
|
-
|
|
521
|
+
if (s == null) continue;
|
|
522
|
+
let raw = null;
|
|
523
|
+
if (typeof s.score === "string") raw = s.score;
|
|
524
|
+
else if (typeof s.score === "object" && s.score && typeof s.score.value === "string") {
|
|
525
|
+
raw = s.score.value; // Finding 19
|
|
526
|
+
}
|
|
527
|
+
if (raw == null) continue;
|
|
528
|
+
const v = raw.trim();
|
|
390
529
|
// Bare numeric score (no vector prefix).
|
|
391
530
|
const num = parseFloat(v);
|
|
392
531
|
if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
|
|
393
|
-
if (
|
|
532
|
+
if (bareScore == null) bareScore = num;
|
|
394
533
|
continue;
|
|
395
534
|
}
|
|
396
535
|
const m = v.match(/^CVSS:(\d+\.\d+)/);
|
|
397
536
|
if (!m) continue;
|
|
398
537
|
const ver = parseFloat(m[1]);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
538
|
+
// Keep the highest score within each major version.
|
|
539
|
+
const prev = vectorsByVersion.get(ver);
|
|
540
|
+
if (!prev) vectorsByVersion.set(ver, v);
|
|
403
541
|
}
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
542
|
+
// F10: try versions in descending order. CVSS 4.0 derivation is not yet
|
|
543
|
+
// implemented here — if v4 was the highest but can't be computed, walk
|
|
544
|
+
// down to v3.x. Only return null when ALL versions fail.
|
|
545
|
+
const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
|
|
546
|
+
let bestVector = null;
|
|
547
|
+
for (const ver of versions) {
|
|
548
|
+
const candidate = vectorsByVersion.get(ver);
|
|
549
|
+
if (!candidate) continue;
|
|
550
|
+
bestVector = candidate;
|
|
551
|
+
const tail = candidate.match(/\/(\d+(?:\.\d+)?)$/);
|
|
410
552
|
if (tail) {
|
|
411
|
-
const
|
|
412
|
-
if (
|
|
553
|
+
const t = parseFloat(tail[1]);
|
|
554
|
+
if (t >= 0 && t <= 10) { score = t; break; }
|
|
555
|
+
}
|
|
556
|
+
if (/^CVSS:3\./.test(candidate)) {
|
|
557
|
+
const computed = cvss3BaseScore(candidate);
|
|
558
|
+
if (computed != null) { score = computed; break; }
|
|
413
559
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
560
|
+
// v4 has no in-module computer — keep walking down to the next
|
|
561
|
+
// version. The bestVector tracker holds whatever was tried last;
|
|
562
|
+
// overwrite it with the next computable on the loop iteration.
|
|
563
|
+
}
|
|
564
|
+
// If we landed on an uncomputable v4 vector but a lower-version vector
|
|
565
|
+
// was usable, prefer the lower-version one's vector string so callers
|
|
566
|
+
// don't get a v4 vector + null score combo when v3 was available.
|
|
567
|
+
if (score == null && versions.length > 0) {
|
|
568
|
+
for (const ver of versions) {
|
|
569
|
+
if (ver >= 4) continue;
|
|
570
|
+
const candidate = vectorsByVersion.get(ver);
|
|
571
|
+
if (candidate) { bestVector = candidate; break; }
|
|
417
572
|
}
|
|
418
573
|
}
|
|
574
|
+
// F10 fix: if score is still null but we have a v4 vector + a bare
|
|
575
|
+
// score that came from a v3-only severity entry, prefer the v3 vector
|
|
576
|
+
// string when one exists. (Handles the case described in the audit:
|
|
577
|
+
// v4 is the highest version but uncomputable; a v3 vector with 9.8
|
|
578
|
+
// sits alongside. Without this fallback we lose the 9.8.)
|
|
579
|
+
if (score == null && bareScore != null) score = bareScore;
|
|
419
580
|
return { score, vector: bestVector };
|
|
420
581
|
}
|
|
421
582
|
|
|
@@ -425,7 +586,8 @@ function extractCvss(rec) {
|
|
|
425
586
|
*/
|
|
426
587
|
function inferType(rec) {
|
|
427
588
|
const ecos = new Set();
|
|
428
|
-
|
|
589
|
+
const affected = Array.isArray(rec?.affected) ? rec.affected : [];
|
|
590
|
+
for (const a of affected) {
|
|
429
591
|
if (a?.package?.ecosystem) ecos.add(String(a.package.ecosystem).toLowerCase());
|
|
430
592
|
}
|
|
431
593
|
if (ecos.has("pypi") || ecos.has("pip")) return "supply-chain-pypi";
|
|
@@ -439,6 +601,23 @@ function inferType(rec) {
|
|
|
439
601
|
return "supply-chain-other";
|
|
440
602
|
}
|
|
441
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Validate + slice a published/modified timestamp string. Findings 2 + 17:
|
|
606
|
+
* - typeof guard so non-string inputs (number, object, undefined) become
|
|
607
|
+
* null instead of throwing on .slice().
|
|
608
|
+
* - ISO-prefix regex + year sanity bound so garbage like "yesterday" or
|
|
609
|
+
* "0001-01-01" doesn't pollute downstream surfaces.
|
|
610
|
+
*/
|
|
611
|
+
function safeDateSlice(value) {
|
|
612
|
+
if (typeof value !== "string") return null;
|
|
613
|
+
const head = value.slice(0, 10);
|
|
614
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(head)) return null;
|
|
615
|
+
const year = parseInt(head.slice(0, 4), 10);
|
|
616
|
+
const now = new Date().getUTCFullYear();
|
|
617
|
+
if (!Number.isFinite(year) || year < 1990 || year > now + 1) return null;
|
|
618
|
+
return head;
|
|
619
|
+
}
|
|
620
|
+
|
|
442
621
|
/**
|
|
443
622
|
* Normalize an OSV record into the exceptd catalog draft shape. Returns
|
|
444
623
|
* `{ [catalogKey]: <draft-entry> }` so callers can spread it into the
|
|
@@ -450,7 +629,10 @@ function inferType(rec) {
|
|
|
450
629
|
* mark the entry for the strict catalog validator (warn, not error).
|
|
451
630
|
*/
|
|
452
631
|
function normalizeAdvisory(rec) {
|
|
453
|
-
if (!rec ||
|
|
632
|
+
if (!rec || rec.id == null) return null;
|
|
633
|
+
// Trim id so trailing whitespace doesn't bleed into pickCatalogKey + key.
|
|
634
|
+
if (typeof rec.id === "string") rec = { ...rec, id: rec.id.trim() };
|
|
635
|
+
if (!rec.id) return null;
|
|
454
636
|
const catalogKey = pickCatalogKey(rec);
|
|
455
637
|
if (!catalogKey) return null;
|
|
456
638
|
|
|
@@ -463,7 +645,9 @@ function normalizeAdvisory(rec) {
|
|
|
463
645
|
|
|
464
646
|
const affectedPackages = [];
|
|
465
647
|
const affectedVersions = [];
|
|
466
|
-
|
|
648
|
+
// Finding 3: rec.affected might not be an array — guard before iterating.
|
|
649
|
+
const affectedList = Array.isArray(rec.affected) ? rec.affected : [];
|
|
650
|
+
for (const a of affectedList) {
|
|
467
651
|
const pkg = a?.package || {};
|
|
468
652
|
if (pkg.name && pkg.ecosystem) {
|
|
469
653
|
affectedPackages.push(`${pkg.ecosystem}:${pkg.name}`);
|
|
@@ -472,14 +656,46 @@ function normalizeAdvisory(rec) {
|
|
|
472
656
|
for (const v of versions) {
|
|
473
657
|
affectedVersions.push(`${pkg.name || "?"} == ${v}`);
|
|
474
658
|
}
|
|
475
|
-
//
|
|
659
|
+
// Finding 16: walk events sequentially. OSV emits a stream of
|
|
660
|
+
// introduced/fixed/last_affected events; the historical implementation
|
|
661
|
+
// collected the FIRST `introduced` + FIRST `fixed` per range and
|
|
662
|
+
// emitted one range, losing re-introduction cycles (an introduced ->
|
|
663
|
+
// fixed -> introduced -> fixed sequence collapsed to one range).
|
|
664
|
+
// Sequential pairing produces ONE entry per (introduced, fixed |
|
|
665
|
+
// last-known-vulnerable) pair instead.
|
|
476
666
|
const ranges = Array.isArray(a.ranges) ? a.ranges : [];
|
|
477
667
|
for (const r of ranges) {
|
|
478
668
|
const events = Array.isArray(r.events) ? r.events : [];
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
669
|
+
let openIntro = null;
|
|
670
|
+
let lastKnownVulnerable = null;
|
|
671
|
+
for (const e of events) {
|
|
672
|
+
if (!e || typeof e !== "object") continue;
|
|
673
|
+
if (typeof e.introduced === "string") {
|
|
674
|
+
// Flush any prior open pair with whatever upper bound we have.
|
|
675
|
+
if (openIntro != null) {
|
|
676
|
+
const upper = lastKnownVulnerable ? `, <= ${lastKnownVulnerable}` : "";
|
|
677
|
+
affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}${upper}`);
|
|
678
|
+
lastKnownVulnerable = null;
|
|
679
|
+
}
|
|
680
|
+
openIntro = e.introduced;
|
|
681
|
+
} else if (typeof e.fixed === "string") {
|
|
682
|
+
if (openIntro != null) {
|
|
683
|
+
affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}, < ${e.fixed}`);
|
|
684
|
+
openIntro = null;
|
|
685
|
+
lastKnownVulnerable = null;
|
|
686
|
+
} else {
|
|
687
|
+
// Defensive: fixed-without-introduced — emit a fixed-only marker.
|
|
688
|
+
affectedVersions.push(`${pkg.name || "?"} < ${e.fixed}`);
|
|
689
|
+
}
|
|
690
|
+
} else if (typeof e.last_affected === "string") {
|
|
691
|
+
lastKnownVulnerable = e.last_affected;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Trailing open range — no `fixed` ever observed. Emit as
|
|
695
|
+
// `>= introduced` (optionally with last_known_vulnerable upper).
|
|
696
|
+
if (openIntro != null) {
|
|
697
|
+
const upper = lastKnownVulnerable ? `, <= ${lastKnownVulnerable}` : "";
|
|
698
|
+
affectedVersions.push(`${pkg.name || "?"} >= ${openIntro}${upper}`);
|
|
483
699
|
}
|
|
484
700
|
}
|
|
485
701
|
}
|
|
@@ -497,8 +713,10 @@ function normalizeAdvisory(rec) {
|
|
|
497
713
|
}
|
|
498
714
|
|
|
499
715
|
// Reference URLs — OSV `references` is `[{ type, url }, ...]`.
|
|
716
|
+
// Finding 20: guard non-array references silently truncating to [].
|
|
500
717
|
const refUrls = [];
|
|
501
|
-
|
|
718
|
+
const refList = Array.isArray(rec.references) ? rec.references : [];
|
|
719
|
+
for (const r of refList) {
|
|
502
720
|
if (r && typeof r.url === "string") refUrls.push(r.url);
|
|
503
721
|
}
|
|
504
722
|
|
|
@@ -512,8 +730,9 @@ function normalizeAdvisory(rec) {
|
|
|
512
730
|
const pending = severityWord === "critical" || (score != null && score >= 9.0);
|
|
513
731
|
|
|
514
732
|
const today = new Date().toISOString().slice(0, 10);
|
|
515
|
-
|
|
516
|
-
const
|
|
733
|
+
// Finding 2 + 17: type-safe + format-validated date slicing.
|
|
734
|
+
const published = safeDateSlice(rec.published);
|
|
735
|
+
const modified = safeDateSlice(rec.modified);
|
|
517
736
|
|
|
518
737
|
// OSV.dev canonical advisory URL — used as the primary vendor advisory.
|
|
519
738
|
const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
|
|
@@ -600,11 +819,14 @@ function normalizeAdvisory(rec) {
|
|
|
600
819
|
* Build a refresh diff for the refresh-external orchestrator. v0.12.10
|
|
601
820
|
* supports targeted seeding: when `ctx.osv_ids` is populated, fetch each
|
|
602
821
|
* id and emit one `_new_entry` diff per record that isn't already in the
|
|
603
|
-
* local catalog.
|
|
604
|
-
*
|
|
822
|
+
* local catalog. Finding 9: when the record already exists and a watched
|
|
823
|
+
* field has dropped from populated -> null, emit a `field_dropped` diff
|
|
824
|
+
* so curators see the upstream regression instead of silently absorbing it.
|
|
605
825
|
*/
|
|
606
826
|
async function buildDiff(ctx) {
|
|
607
|
-
|
|
827
|
+
// Finding 8: trim ids defensively at the entry seam.
|
|
828
|
+
const rawIds = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
|
|
829
|
+
const ids = rawIds.map((x) => (typeof x === "string" ? x.trim() : "")).filter(Boolean);
|
|
608
830
|
if (ids.length === 0) {
|
|
609
831
|
return {
|
|
610
832
|
status: "ok",
|
|
@@ -615,7 +837,8 @@ async function buildDiff(ctx) {
|
|
|
615
837
|
summary: "OSV: no ids requested (set ctx.osv_ids to seed a draft, or pass --advisory <MAL-...> for one-shot import).",
|
|
616
838
|
};
|
|
617
839
|
}
|
|
618
|
-
const
|
|
840
|
+
const cveCatalog = ctx.cveCatalog || {};
|
|
841
|
+
const existingKeys = new Set(Object.keys(cveCatalog));
|
|
619
842
|
const diffs = [];
|
|
620
843
|
// F7: distinguish unreachable (fetch failed, network or 5xx) from
|
|
621
844
|
// normalize-rejected (record fetched but normalization produced null).
|
|
@@ -623,15 +846,47 @@ async function buildDiff(ctx) {
|
|
|
623
846
|
// network outage or a malformed upstream record.
|
|
624
847
|
let unreachable = 0;
|
|
625
848
|
let normalizeErrors = 0;
|
|
849
|
+
// Finding 18: ids that ARE in the catalog but skipped because of overlap
|
|
850
|
+
// are not "errors"; surface them so the summary doesn't read as silently
|
|
851
|
+
// dropping work. Particularly useful when a curator dispatches the same
|
|
852
|
+
// batch twice and wonders why nothing happened.
|
|
853
|
+
let ghsaOnlySkipped = 0;
|
|
626
854
|
for (const id of ids) {
|
|
627
|
-
const r = await fetchAdvisoryById(id);
|
|
855
|
+
const r = await fetchAdvisoryById(id, { airGap: ctx.airGap });
|
|
628
856
|
if (!r.ok) { unreachable++; continue; }
|
|
629
857
|
const rec = r.advisories[0];
|
|
630
858
|
if (!rec) { unreachable++; continue; }
|
|
631
859
|
const normalized = normalizeAdvisory(rec);
|
|
632
860
|
if (!normalized) { normalizeErrors++; continue; }
|
|
633
861
|
const key = Object.keys(normalized)[0];
|
|
634
|
-
if (existingKeys.has(key))
|
|
862
|
+
if (existingKeys.has(key)) {
|
|
863
|
+
// Finding 9: field-dropped detection. Compare watched fields between
|
|
864
|
+
// the existing local entry and the freshly-normalized one. Emit a
|
|
865
|
+
// `field_dropped` diff per regression rather than a `_new_entry`.
|
|
866
|
+
const before = cveCatalog[key] || {};
|
|
867
|
+
const after = normalized[key];
|
|
868
|
+
let dropped = false;
|
|
869
|
+
for (const field of FIELD_DROPPED_WATCH) {
|
|
870
|
+
const had = before[field];
|
|
871
|
+
const has = after[field];
|
|
872
|
+
const wasPopulated = had !== null && had !== undefined && had !== "" && had !== false;
|
|
873
|
+
const isNowEmpty = has === null || has === undefined;
|
|
874
|
+
if (wasPopulated && isNowEmpty) {
|
|
875
|
+
diffs.push({
|
|
876
|
+
id: key,
|
|
877
|
+
field,
|
|
878
|
+
before: had,
|
|
879
|
+
after: null,
|
|
880
|
+
severity: null,
|
|
881
|
+
source: "osv",
|
|
882
|
+
variant: "field_dropped",
|
|
883
|
+
});
|
|
884
|
+
dropped = true;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (!dropped) ghsaOnlySkipped++;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
635
890
|
diffs.push({
|
|
636
891
|
id: key,
|
|
637
892
|
field: "_new_entry",
|
|
@@ -642,13 +897,15 @@ async function buildDiff(ctx) {
|
|
|
642
897
|
});
|
|
643
898
|
}
|
|
644
899
|
const errors = unreachable + normalizeErrors;
|
|
900
|
+
const summary = `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected, ${ghsaOnlySkipped} ghsa_only_skipped.`;
|
|
645
901
|
return {
|
|
646
902
|
status: errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial",
|
|
647
903
|
diffs,
|
|
648
904
|
errors,
|
|
649
905
|
unreachable_count: unreachable,
|
|
650
906
|
normalize_error_count: normalizeErrors,
|
|
651
|
-
|
|
907
|
+
ghsa_only_skipped: ghsaOnlySkipped,
|
|
908
|
+
summary,
|
|
652
909
|
};
|
|
653
910
|
}
|
|
654
911
|
|
|
@@ -661,4 +918,6 @@ module.exports = {
|
|
|
661
918
|
extractCvss,
|
|
662
919
|
cvss3BaseScore,
|
|
663
920
|
OSV_ID_PREFIXES,
|
|
921
|
+
FIELD_DROPPED_WATCH,
|
|
922
|
+
safeDateSlice,
|
|
664
923
|
};
|