@blamejs/exceptd-skills 0.12.9 → 0.12.11

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.
@@ -0,0 +1,664 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/source-osv.js
5
+ *
6
+ * OSV.dev fetcher. OSV aggregates OSSF Malicious Packages (MAL-*), Snyk
7
+ * (SNYK-*), GitHub Advisory Database (GHSA-*), RustSec (RUSTSEC-*),
8
+ * Mageia (MGASA-*), Go Vuln DB (GO-*), Ubuntu USN (USN-*), and several
9
+ * other ecosystems into a single unauthenticated API.
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.
37
+ */
38
+
39
+ const https = require("https");
40
+ const fs = require("fs");
41
+
42
+ // OSV_HOST_OVERRIDE lets tests redirect the network call to a local HTTP
43
+ // server bound on 127.0.0.1:<port>. The override accepts either a bare
44
+ // `host:port` string or a full `http://host:port` URL. When set, the
45
+ // underlying request switches from `https` to `http` so the test server
46
+ // doesn't need a TLS cert. Production callers never set this.
47
+ const OSV_HOST = "api.osv.dev";
48
+ const REQUEST_TIMEOUT_MS = 10000;
49
+ const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
50
+
51
+ // Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is intentionally
52
+ // NOT in this list — `seedSingleAdvisory` in lib/refresh-external.js routes
53
+ // CVE-* and GHSA-* through `source-ghsa` because GHSA carries richer field
54
+ // coverage (cvss object, vulnerable_version_range string, ghsa_id linkage)
55
+ // than OSV's import of the same advisories. Keep this list in sync with the
56
+ // dispatcher in lib/refresh-external.js — adding a new prefix here is not
57
+ // enough; the dispatcher's --advisory regex must also accept it.
58
+ const OSV_ID_PREFIXES = [
59
+ "MAL-", // OSSF Malicious Packages
60
+ "SNYK-", // Snyk
61
+ "RUSTSEC-", // RustSec
62
+ "GO-", // Go vuln DB
63
+ "USN-", // Ubuntu Security Notices
64
+ "UVI-", // Ubuntu (alternate prefix used in some OSV mirrors)
65
+ "MGASA-", // Mageia
66
+ "OSV-", // OSV-internal
67
+ "PYSEC-", // Python Security
68
+ "DLA-", // Debian LTS
69
+ "DSA-", // Debian Security
70
+ "DTSA-", // Debian Testing Security
71
+ "BIT-", // Bitnami
72
+ "ALAS-", // Amazon Linux
73
+ "ALSA-", // AlmaLinux
74
+ "RHSA-", // Red Hat
75
+ "RLSA-", // Rocky Linux
76
+ "SUSE-", // SUSE
77
+ "OPENSUSE-", // openSUSE
78
+ ];
79
+
80
+ /**
81
+ * Return true when `id` looks like an OSV-native primary key (i.e. NOT a
82
+ * CVE-* identifier and NOT a GHSA-* identifier). Both CVE-* and GHSA-*
83
+ * route through `source-ghsa` for richer field coverage.
84
+ */
85
+ function isOsvId(id) {
86
+ if (!id || typeof id !== "string") return false;
87
+ const up = id.toUpperCase();
88
+ if (/^CVE-\d{4}-\d+$/.test(up)) return false;
89
+ if (up.startsWith("GHSA-")) return false;
90
+ return OSV_ID_PREFIXES.some((p) => up.startsWith(p));
91
+ }
92
+
93
+ /**
94
+ * Resolve the OSV transport target. When OSV_HOST_OVERRIDE is set the
95
+ * request switches to plain HTTP on the override host:port so test
96
+ * harnesses can stand up a local server without TLS. Production omits the
97
+ * override entirely and lands on api.osv.dev over HTTPS.
98
+ */
99
+ function osvTransport() {
100
+ const override = process.env.OSV_HOST_OVERRIDE;
101
+ if (!override) return { mod: https, host: OSV_HOST, port: 443 };
102
+ // Accept either "host:port" or a full URL.
103
+ let raw = override.trim();
104
+ if (/^https?:\/\//i.test(raw)) {
105
+ const u = new URL(raw);
106
+ return { mod: require("http"), host: u.hostname, port: parseInt(u.port, 10) || 80 };
107
+ }
108
+ const [h, p] = raw.split(":");
109
+ return { mod: require("http"), host: h || "127.0.0.1", port: parseInt(p, 10) || 80 };
110
+ }
111
+
112
+ /**
113
+ * Low-level GET against OSV. Resolves to { ok, record|error, source }.
114
+ * Honors OSV_HOST_OVERRIDE for offline tests.
115
+ */
116
+ function osvGet(reqPath, timeoutMs = REQUEST_TIMEOUT_MS) {
117
+ return new Promise((resolve) => {
118
+ const { mod, host, port } = osvTransport();
119
+ const req = mod.get({
120
+ host,
121
+ port,
122
+ path: reqPath,
123
+ headers: {
124
+ "Accept": "application/json",
125
+ "User-Agent": USER_AGENT,
126
+ },
127
+ timeout: timeoutMs,
128
+ }, (res) => {
129
+ if (res.statusCode !== 200) {
130
+ res.resume();
131
+ const status = res.statusCode;
132
+ const error = status === 429
133
+ ? `OSV rate-limited (HTTP 429)`
134
+ : `OSV returned HTTP ${status}`;
135
+ return resolve({ ok: false, error, status, source: "offline" });
136
+ }
137
+ const chunks = [];
138
+ res.on("data", (c) => chunks.push(c));
139
+ res.on("end", () => {
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" });
145
+ }
146
+ });
147
+ });
148
+ req.on("timeout", () => req.destroy(new Error("OSV request timed out")));
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) {
174
+ res.resume();
175
+ const status = res.statusCode;
176
+ const error = status === 429
177
+ ? `OSV rate-limited (HTTP 429)`
178
+ : `OSV returned HTTP ${status}`;
179
+ return resolve({ ok: false, error, status, source: "offline" });
180
+ }
181
+ const chunks = [];
182
+ res.on("data", (c) => chunks.push(c));
183
+ res.on("end", () => {
184
+ try {
185
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
186
+ resolve({ ok: true, record: parsed, source: "osv-api" });
187
+ } catch (e) {
188
+ resolve({ ok: false, error: `parse: ${e.message}`, source: "offline" });
189
+ }
190
+ });
191
+ });
192
+ req.on("timeout", () => req.destroy(new Error("OSV request timed out")));
193
+ req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
194
+ req.write(payload);
195
+ req.end();
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Read EXCEPTD_OSV_FIXTURE and return a structured envelope. Matches the
201
+ * GHSA-source convention: on any failure (missing file, malformed JSON,
202
+ * root not object/array) return `{ ok: false, error, source: "offline" }`
203
+ * rather than throw — operators on the CLI surface get a structured error
204
+ * instead of a Node stack trace.
205
+ *
206
+ * Returns:
207
+ * null when env var is unset
208
+ * { ok: true, advisories: [...], source } on success
209
+ * { ok: false, error, source: "offline" } on any failure
210
+ */
211
+ function readFixture() {
212
+ const fp = process.env.EXCEPTD_OSV_FIXTURE;
213
+ if (!fp) return null;
214
+ let raw;
215
+ try {
216
+ raw = fs.readFileSync(fp, "utf8");
217
+ } catch (e) {
218
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
219
+ }
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(raw);
223
+ } catch (e) {
224
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
225
+ }
226
+ if (parsed == null || (typeof parsed !== "object")) {
227
+ return { ok: false, error: `fixture: root must be an OSV record object or array (got ${typeof parsed})`, source: "offline" };
228
+ }
229
+ return { ok: true, advisories: Array.isArray(parsed) ? parsed : [parsed], source: "fixture" };
230
+ }
231
+
232
+ /**
233
+ * Fetch a single OSV record by id (MAL-*, GHSA-*, SNYK-*, RUSTSEC-*, etc.).
234
+ *
235
+ * Returns shape matches source-ghsa.fetchAdvisoryById:
236
+ * { ok: true, advisories: [<osv_record>], source: "osv-api" | "fixture" }
237
+ * { ok: false, error, source: "offline" | "fixture" }
238
+ */
239
+ async function fetchAdvisoryById(id, opts = {}) {
240
+ if (!id || typeof id !== "string") {
241
+ return { ok: false, error: "id is required (MAL-*, SNYK-*, RUSTSEC-*, etc.)", source: "offline" };
242
+ }
243
+ // 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
245
+ // lowercase ids from grep/jq don't get a surprising 404 from the network
246
+ // path. Fixture lookup already case-folds, so this normalization is a
247
+ // no-op there but harmless.
248
+ id = id.toUpperCase();
249
+ const fixture = readFixture();
250
+ if (fixture) {
251
+ if (!fixture.ok) return fixture; // F1: structured error envelope
252
+ const want = id;
253
+ const match = fixture.advisories.find((rec) => {
254
+ const recId = (rec && rec.id) ? String(rec.id).toUpperCase() : null;
255
+ if (recId === want) return true;
256
+ const aliases = Array.isArray(rec?.aliases) ? rec.aliases.map((a) => String(a).toUpperCase()) : [];
257
+ return aliases.includes(want);
258
+ });
259
+ if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
260
+ return { ok: true, advisories: [match], source: "fixture" };
261
+ }
262
+ const result = await osvGet(`/v1/vulns/${encodeURIComponent(id)}`, opts.timeoutMs);
263
+ if (!result.ok) return result;
264
+ return { ok: true, advisories: [result.record], source: "osv-api" };
265
+ }
266
+
267
+ /**
268
+ * List advisories for a package, optionally filtered to a specific version.
269
+ * v0.12.10 ships the network path; bulk-import callers are a v0.13 follow-up.
270
+ */
271
+ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
272
+ if (!name || !ecosystem) {
273
+ return { ok: false, error: "name and ecosystem are required", source: "offline" };
274
+ }
275
+ const fixture = readFixture();
276
+ if (fixture) {
277
+ if (!fixture.ok) return fixture; // F1: structured error envelope
278
+ // Best-effort fixture filtering: match any record whose `affected[]`
279
+ // contains the requested package + ecosystem (+ version when set).
280
+ const matches = fixture.advisories.filter((rec) => {
281
+ const affected = Array.isArray(rec?.affected) ? rec.affected : [];
282
+ return affected.some((a) => {
283
+ const pkg = a?.package || {};
284
+ if ((pkg.name || "").toLowerCase() !== name.toLowerCase()) return false;
285
+ if ((pkg.ecosystem || "").toLowerCase() !== ecosystem.toLowerCase()) return false;
286
+ if (!version) return true;
287
+ const versions = Array.isArray(a.versions) ? a.versions : [];
288
+ return versions.includes(version);
289
+ });
290
+ });
291
+ return { ok: true, advisories: matches, source: "fixture" };
292
+ }
293
+ const body = { package: { name, ecosystem } };
294
+ if (version) body.version = version;
295
+ const r = await osvPost("/v1/query", body, opts.timeoutMs);
296
+ if (!r.ok) return r;
297
+ const vulns = Array.isArray(r.record?.vulns) ? r.record.vulns : [];
298
+ return { ok: true, advisories: vulns, source: "osv-api" };
299
+ }
300
+
301
+ /**
302
+ * Pick the catalog key for an OSV record. If `aliases` contains a CVE-*
303
+ * value, prefer that (preserving the existing CVE-keyed convention).
304
+ * Otherwise return the OSV id verbatim — MAL-*, SNYK-*, RUSTSEC-*, etc.
305
+ */
306
+ function pickCatalogKey(rec) {
307
+ if (!rec || !rec.id) return null;
308
+ const aliases = Array.isArray(rec.aliases) ? rec.aliases : [];
309
+ const cve = aliases.find((a) => /^CVE-\d{4}-\d+$/i.test(String(a)));
310
+ return cve ? String(cve).toUpperCase() : String(rec.id);
311
+ }
312
+
313
+ /**
314
+ * CVSS 3.1 base-score computation from a vector string. Implements Table 6
315
+ * of the FIRST CVSS 3.1 specification. Used when an OSV record carries a
316
+ * vector but no embedded numeric score (the common case for MAL-* records).
317
+ * Returns null on malformed input.
318
+ *
319
+ * Reference: https://www.first.org/cvss/v3.1/specification-document
320
+ */
321
+ function cvss3BaseScore(vector) {
322
+ if (typeof vector !== "string") return null;
323
+ const m = vector.match(/^CVSS:3\.\d\/(.+)$/);
324
+ if (!m) return null;
325
+ const parts = m[1].split("/");
326
+ const metrics = {};
327
+ for (const p of parts) {
328
+ const [k, v] = p.split(":");
329
+ if (!k || !v) return null;
330
+ metrics[k] = v;
331
+ }
332
+ // Required metrics — bail if any are missing.
333
+ for (const k of ["AV", "AC", "PR", "UI", "S", "C", "I", "A"]) {
334
+ if (!metrics[k]) return null;
335
+ }
336
+ const AV_W = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
337
+ const AC_W = { L: 0.77, H: 0.44 };
338
+ const UI_W = { N: 0.85, R: 0.62 };
339
+ const CIA_W = { H: 0.56, L: 0.22, N: 0 };
340
+ // PR weights depend on Scope.
341
+ const PR_W_U = { N: 0.85, L: 0.62, H: 0.27 };
342
+ const PR_W_C = { N: 0.85, L: 0.68, H: 0.5 };
343
+ const scope = metrics.S;
344
+ if (scope !== "U" && scope !== "C") return null;
345
+ const av = AV_W[metrics.AV];
346
+ const ac = AC_W[metrics.AC];
347
+ const ui = UI_W[metrics.UI];
348
+ const pr = (scope === "C" ? PR_W_C : PR_W_U)[metrics.PR];
349
+ const c = CIA_W[metrics.C];
350
+ const i = CIA_W[metrics.I];
351
+ const a = CIA_W[metrics.A];
352
+ if ([av, ac, ui, pr, c, i, a].some((x) => x == null)) return null;
353
+ const iss = 1 - ((1 - c) * (1 - i) * (1 - a));
354
+ let impact;
355
+ if (scope === "U") {
356
+ impact = 6.42 * iss;
357
+ } else {
358
+ impact = 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15);
359
+ }
360
+ if (impact <= 0) return 0.0;
361
+ const exploitability = 8.22 * av * ac * pr * ui;
362
+ let base;
363
+ if (scope === "U") {
364
+ base = Math.min(impact + exploitability, 10);
365
+ } else {
366
+ base = Math.min(1.08 * (impact + exploitability), 10);
367
+ }
368
+ // roundUp1: round up to one decimal (CVSS 3.1 §7.1).
369
+ const rounded = Math.ceil(base * 10) / 10;
370
+ if (!Number.isFinite(rounded) || rounded < 0 || rounded > 10) return null;
371
+ return rounded;
372
+ }
373
+
374
+ /**
375
+ * Pull a numeric CVSS score + vector out of an OSV severity[] entry. CVSS
376
+ * 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 regardless of
378
+ * array order. When the OSV record has no embedded numeric tail, the score
379
+ * is computed from the vector itself via cvss3BaseScore(). Returns null
380
+ * components when nothing parseable is present.
381
+ */
382
+ function extractCvss(rec) {
383
+ const sev = Array.isArray(rec?.severity) ? rec.severity : [];
384
+ let score = null;
385
+ let bestVector = null;
386
+ let bestVersion = 0;
387
+ for (const s of sev) {
388
+ if (typeof s?.score !== "string") continue;
389
+ const v = s.score.trim();
390
+ // Bare numeric score (no vector prefix).
391
+ const num = parseFloat(v);
392
+ if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
393
+ if (score == null) score = num;
394
+ continue;
395
+ }
396
+ const m = v.match(/^CVSS:(\d+\.\d+)/);
397
+ if (!m) continue;
398
+ const ver = parseFloat(m[1]);
399
+ if (ver > bestVersion) {
400
+ bestVersion = ver;
401
+ bestVector = v;
402
+ }
403
+ }
404
+ // If we picked a vector, try to read an embedded score from the trailing
405
+ // fragment (some Snyk records carry it as ".../9.3"). Otherwise compute
406
+ // it from the vector for CVSS 3.x. CVSS 4.0 base-score derivation is
407
+ // intentionally not implemented here — that's a v0.13 follow-up.
408
+ if (bestVector && score == null) {
409
+ const tail = bestVector.match(/\/(\d+(?:\.\d+)?)$/);
410
+ if (tail) {
411
+ const candidate = parseFloat(tail[1]);
412
+ if (candidate >= 0 && candidate <= 10) score = candidate;
413
+ }
414
+ if (score == null && /^CVSS:3\./.test(bestVector)) {
415
+ const computed = cvss3BaseScore(bestVector);
416
+ if (computed != null) score = computed;
417
+ }
418
+ }
419
+ return { score, vector: bestVector };
420
+ }
421
+
422
+ /**
423
+ * Coarse package-ecosystem inference for the catalog `type` field. Mirrors
424
+ * the same heuristic used by source-ghsa.
425
+ */
426
+ function inferType(rec) {
427
+ const ecos = new Set();
428
+ for (const a of (rec?.affected || [])) {
429
+ if (a?.package?.ecosystem) ecos.add(String(a.package.ecosystem).toLowerCase());
430
+ }
431
+ if (ecos.has("pypi") || ecos.has("pip")) return "supply-chain-pypi";
432
+ if (ecos.has("npm")) return "supply-chain-npm";
433
+ if (ecos.has("maven")) return "supply-chain-maven";
434
+ if (ecos.has("rubygems")) return "supply-chain-gem";
435
+ if (ecos.has("crates.io") || ecos.has("cargo")) return "supply-chain-rust";
436
+ if (ecos.has("go")) return "supply-chain-go";
437
+ if (ecos.has("nuget")) return "supply-chain-nuget";
438
+ if (ecos.has("packagist")) return "supply-chain-composer";
439
+ return "supply-chain-other";
440
+ }
441
+
442
+ /**
443
+ * Normalize an OSV record into the exceptd catalog draft shape. Returns
444
+ * `{ [catalogKey]: <draft-entry> }` so callers can spread it into the
445
+ * catalog object directly. Returns null if the record is unusable.
446
+ *
447
+ * Editorial fields (framework_control_gaps, atlas_refs, attack_refs,
448
+ * rwep_factors) are left null — the seven-phase playbook flow or a human
449
+ * reviewer fills these in. `_auto_imported: true` + `_draft: true` flags
450
+ * mark the entry for the strict catalog validator (warn, not error).
451
+ */
452
+ function normalizeAdvisory(rec) {
453
+ if (!rec || !rec.id) return null;
454
+ const catalogKey = pickCatalogKey(rec);
455
+ if (!catalogKey) return null;
456
+
457
+ const aliases = Array.isArray(rec.aliases) ? rec.aliases.slice() : [];
458
+ // If the catalog key came from aliases (CVE-*), put the OSV id back into
459
+ // the aliases array so it stays discoverable.
460
+ if (catalogKey !== rec.id && !aliases.includes(rec.id)) aliases.push(rec.id);
461
+
462
+ const { score, vector } = extractCvss(rec);
463
+
464
+ const affectedPackages = [];
465
+ const affectedVersions = [];
466
+ for (const a of (rec.affected || [])) {
467
+ const pkg = a?.package || {};
468
+ if (pkg.name && pkg.ecosystem) {
469
+ affectedPackages.push(`${pkg.ecosystem}:${pkg.name}`);
470
+ }
471
+ const versions = Array.isArray(a.versions) ? a.versions : [];
472
+ for (const v of versions) {
473
+ affectedVersions.push(`${pkg.name || "?"} == ${v}`);
474
+ }
475
+ // Range bounds: surface "introduced/fixed" pairs as a textual range.
476
+ const ranges = Array.isArray(a.ranges) ? a.ranges : [];
477
+ for (const r of ranges) {
478
+ const events = Array.isArray(r.events) ? r.events : [];
479
+ const intro = events.find((e) => e.introduced)?.introduced;
480
+ const fixed = events.find((e) => e.fixed)?.fixed;
481
+ if (intro || fixed) {
482
+ affectedVersions.push(`${pkg.name || "?"} >= ${intro || "0"}` + (fixed ? `, < ${fixed}` : ""));
483
+ }
484
+ }
485
+ }
486
+
487
+ // IoC seeding from database_specific.iocs if present (some Snyk + StepSec
488
+ // imported records carry this). Domains + URLs land in c2_indicators so
489
+ // an operator scanning a repo has something to grep for immediately.
490
+ const dsIocs = rec?.database_specific?.iocs || null;
491
+ let iocs = null;
492
+ if (dsIocs && (Array.isArray(dsIocs.domains) || Array.isArray(dsIocs.urls))) {
493
+ const c2 = [];
494
+ if (Array.isArray(dsIocs.domains)) c2.push(...dsIocs.domains.map((d) => `domain: ${d}`));
495
+ if (Array.isArray(dsIocs.urls)) c2.push(...dsIocs.urls.map((u) => `url: ${u}`));
496
+ iocs = { c2_indicators: c2 };
497
+ }
498
+
499
+ // Reference URLs — OSV `references` is `[{ type, url }, ...]`.
500
+ const refUrls = [];
501
+ for (const r of (rec.references || [])) {
502
+ if (r && typeof r.url === "string") refUrls.push(r.url);
503
+ }
504
+
505
+ // Severity wording from CVSS / qualitative hint.
506
+ const severityWord = score != null && score >= 9.0 ? "critical"
507
+ : score != null && score >= 7.0 ? "high"
508
+ : score != null && score >= 4.0 ? "medium"
509
+ : score != null ? "low"
510
+ : null;
511
+
512
+ const pending = severityWord === "critical" || (score != null && score >= 9.0);
513
+
514
+ const today = new Date().toISOString().slice(0, 10);
515
+ const published = (rec.published || "").slice(0, 10) || null;
516
+ const modified = (rec.modified || "").slice(0, 10) || null;
517
+
518
+ // OSV.dev canonical advisory URL — used as the primary vendor advisory.
519
+ const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
520
+
521
+ // F6: dedupe verification_sources. OSV records frequently carry the
522
+ // canonical osv.dev URL in references[] as well, which would otherwise
523
+ // produce a duplicate alongside the prepended `osvUrl`.
524
+ const verification_sources = Array.from(new Set([
525
+ osvUrl,
526
+ ...(/^CVE-/i.test(catalogKey) ? [`https://nvd.nist.gov/vuln/detail/${catalogKey}`] : []),
527
+ ...refUrls.slice(0, 10),
528
+ ]));
529
+
530
+ // F5: EPSS coverage does not extend to non-CVE identifiers. Surface this
531
+ // explicitly so curators know to re-query if MITRE later assigns a CVE
532
+ // id to the entry. Wording mirrors the MAL-2026-3083 catalog entry.
533
+ const isCveKey = /^CVE-/i.test(catalogKey);
534
+ const epss_note = isCveKey
535
+ ? null
536
+ : "EPSS coverage does not extend to non-CVE identifiers. FIRST EPSS API only indexes CVE keys; MAL-* / SNYK-* / GHSA-* / RUSTSEC-* / etc. return no data. Re-query and populate epss_score when MITRE assigns a CVE id and the entry is renamed.";
537
+
538
+ return {
539
+ [catalogKey]: {
540
+ name: rec.summary || rec.id,
541
+ type: inferType(rec),
542
+ cvss_score: score,
543
+ cvss_vector: vector,
544
+ cisa_kev: false,
545
+ cisa_kev_date: null,
546
+ cisa_kev_pending: pending,
547
+ cisa_kev_pending_reason: pending
548
+ ? `OSV severity critical (CVSS ${score}). KEV listing typically follows for critical advisories with confirmed exploitation; verify before publish.`
549
+ : null,
550
+ poc_available: null,
551
+ poc_description: null,
552
+ ai_discovered: null,
553
+ ai_assisted_weaponization: null,
554
+ active_exploitation: severityWord === "critical" ? "suspected" : "unknown",
555
+ affected: affectedPackages.join(", ") || null,
556
+ affected_versions: affectedVersions,
557
+ vector: null,
558
+ complexity: null,
559
+ patch_available: null,
560
+ patch_required_reboot: false,
561
+ live_patch_available: null,
562
+ live_patch_tools: [],
563
+ framework_control_gaps: null,
564
+ atlas_refs: [],
565
+ attack_refs: [],
566
+ rwep_score: null,
567
+ rwep_factors: null,
568
+ rwep_notes: "Auto-imported from OSV.dev. RWEP factors require editorial review before this entry passes the strict catalog gate.",
569
+ epss_score: null,
570
+ epss_percentile: null,
571
+ epss_date: null,
572
+ epss_note,
573
+ epss_source: isCveKey
574
+ ? `https://api.first.org/data/v1/epss?cve=${catalogKey}`
575
+ : null,
576
+ source_verified: published || today,
577
+ verification_sources,
578
+ vendor_advisories: [
579
+ {
580
+ vendor: "OSV.dev",
581
+ advisory_id: rec.id,
582
+ url: osvUrl,
583
+ severity: severityWord,
584
+ published_date: published,
585
+ },
586
+ ],
587
+ iocs,
588
+ aliases,
589
+ _auto_imported: true,
590
+ _draft: true,
591
+ _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.",
592
+ _source_osv_id: rec.id,
593
+ _source_published_at: rec.published || null,
594
+ last_updated: modified || today,
595
+ },
596
+ };
597
+ }
598
+
599
+ /**
600
+ * Build a refresh diff for the refresh-external orchestrator. v0.12.10
601
+ * supports targeted seeding: when `ctx.osv_ids` is populated, fetch each
602
+ * id and emit one `_new_entry` diff per record that isn't already in the
603
+ * local catalog. The broader package-watchlist path (bulk import from
604
+ * a watched-packages list) is deferred to v0.13.
605
+ */
606
+ async function buildDiff(ctx) {
607
+ const ids = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
608
+ if (ids.length === 0) {
609
+ return {
610
+ status: "ok",
611
+ diffs: [],
612
+ errors: 0,
613
+ unreachable_count: 0,
614
+ normalize_error_count: 0,
615
+ summary: "OSV: no ids requested (set ctx.osv_ids to seed a draft, or pass --advisory <MAL-...> for one-shot import).",
616
+ };
617
+ }
618
+ const existingKeys = new Set(Object.keys(ctx.cveCatalog || {}));
619
+ const diffs = [];
620
+ // F7: distinguish unreachable (fetch failed, network or 5xx) from
621
+ // normalize-rejected (record fetched but normalization produced null).
622
+ // Operators triaging a refresh-report want to know whether to chase a
623
+ // network outage or a malformed upstream record.
624
+ let unreachable = 0;
625
+ let normalizeErrors = 0;
626
+ for (const id of ids) {
627
+ const r = await fetchAdvisoryById(id);
628
+ if (!r.ok) { unreachable++; continue; }
629
+ const rec = r.advisories[0];
630
+ if (!rec) { unreachable++; continue; }
631
+ const normalized = normalizeAdvisory(rec);
632
+ if (!normalized) { normalizeErrors++; continue; }
633
+ const key = Object.keys(normalized)[0];
634
+ if (existingKeys.has(key)) continue;
635
+ diffs.push({
636
+ id: key,
637
+ field: "_new_entry",
638
+ before: null,
639
+ after: normalized[key],
640
+ severity: normalized[key].cvss_score != null && normalized[key].cvss_score >= 9.0 ? "critical" : null,
641
+ source: "osv",
642
+ });
643
+ }
644
+ const errors = unreachable + normalizeErrors;
645
+ return {
646
+ status: errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial",
647
+ diffs,
648
+ errors,
649
+ unreachable_count: unreachable,
650
+ normalize_error_count: normalizeErrors,
651
+ summary: `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${unreachable} unreachable, ${normalizeErrors} normalize-rejected.`,
652
+ };
653
+ }
654
+
655
+ module.exports = {
656
+ fetchAdvisoryById,
657
+ fetchAdvisoriesForPackage,
658
+ normalizeAdvisory,
659
+ buildDiff,
660
+ isOsvId,
661
+ extractCvss,
662
+ cvss3BaseScore,
663
+ OSV_ID_PREFIXES,
664
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-13T15:30:27.029Z",
3
+ "_generated_at": "2026-05-13T21:19:34.827Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [