@blamejs/exceptd-skills 0.12.9 → 0.12.10

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,493 @@
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
+ const OSV_HOST = "api.osv.dev";
43
+ const REQUEST_TIMEOUT_MS = 10000;
44
+ const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
45
+
46
+ // Identifier namespaces OSV uses as PRIMARY keys (i.e. that route through
47
+ // this module rather than GHSA's CVE-search path). Keep this list in sync
48
+ // with the dispatcher in lib/refresh-external.js — adding a new prefix
49
+ // here is not enough; the dispatcher's --advisory regex must also accept it.
50
+ const OSV_ID_PREFIXES = [
51
+ "MAL-", // OSSF Malicious Packages
52
+ "GHSA-", // GitHub Security Advisories (OSV import)
53
+ "SNYK-", // Snyk
54
+ "RUSTSEC-", // RustSec
55
+ "GO-", // Go vuln DB
56
+ "USN-", // Ubuntu Security Notices
57
+ "UVI-", // Ubuntu (alternate prefix used in some OSV mirrors)
58
+ "MGASA-", // Mageia
59
+ "OSV-", // OSV-internal
60
+ "PYSEC-", // Python Security
61
+ "DLA-", // Debian LTS
62
+ "DSA-", // Debian Security
63
+ "DTSA-", // Debian Testing Security
64
+ "BIT-", // Bitnami
65
+ "ALAS-", // Amazon Linux
66
+ "ALSA-", // AlmaLinux
67
+ "RHSA-", // Red Hat
68
+ "RLSA-", // Rocky Linux
69
+ "SUSE-", // SUSE
70
+ "OPENSUSE-", // openSUSE
71
+ ];
72
+
73
+ /**
74
+ * Return true when `id` looks like an OSV-native primary key (i.e. NOT a
75
+ * CVE-* identifier). CVE-* identifiers continue to route through the GHSA
76
+ * source because GHSA carries richer field coverage for CVE-keyed records.
77
+ */
78
+ function isOsvId(id) {
79
+ if (!id || typeof id !== "string") return false;
80
+ const up = id.toUpperCase();
81
+ if (/^CVE-\d{4}-\d+$/.test(up)) return false;
82
+ return OSV_ID_PREFIXES.some((p) => up.startsWith(p));
83
+ }
84
+
85
+ /**
86
+ * Low-level HTTPS GET against OSV. Resolves to { ok, record|error, source }.
87
+ */
88
+ function osvGet(path, timeoutMs = REQUEST_TIMEOUT_MS) {
89
+ return new Promise((resolve) => {
90
+ const req = https.get({
91
+ host: OSV_HOST,
92
+ path,
93
+ headers: {
94
+ "Accept": "application/json",
95
+ "User-Agent": USER_AGENT,
96
+ },
97
+ timeout: timeoutMs,
98
+ }, (res) => {
99
+ if (res.statusCode !== 200) {
100
+ res.resume();
101
+ return resolve({ ok: false, error: `OSV returned HTTP ${res.statusCode}`, source: "offline" });
102
+ }
103
+ const chunks = [];
104
+ res.on("data", (c) => chunks.push(c));
105
+ res.on("end", () => {
106
+ try {
107
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
108
+ resolve({ ok: true, record: body, source: "osv-api" });
109
+ } catch (e) {
110
+ resolve({ ok: false, error: `parse: ${e.message}`, source: "offline" });
111
+ }
112
+ });
113
+ });
114
+ req.on("timeout", () => req.destroy(new Error("timeout")));
115
+ req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Low-level HTTPS POST against OSV. Body is JSON-stringified.
121
+ */
122
+ function osvPost(path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
123
+ return new Promise((resolve) => {
124
+ const payload = Buffer.from(JSON.stringify(body), "utf8");
125
+ const req = https.request({
126
+ host: OSV_HOST,
127
+ path,
128
+ method: "POST",
129
+ headers: {
130
+ "Content-Type": "application/json",
131
+ "Content-Length": payload.length,
132
+ "Accept": "application/json",
133
+ "User-Agent": USER_AGENT,
134
+ },
135
+ timeout: timeoutMs,
136
+ }, (res) => {
137
+ if (res.statusCode !== 200) {
138
+ res.resume();
139
+ return resolve({ ok: false, error: `OSV returned HTTP ${res.statusCode}`, source: "offline" });
140
+ }
141
+ const chunks = [];
142
+ res.on("data", (c) => chunks.push(c));
143
+ res.on("end", () => {
144
+ try {
145
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
146
+ resolve({ ok: true, record: parsed, source: "osv-api" });
147
+ } catch (e) {
148
+ resolve({ ok: false, error: `parse: ${e.message}`, source: "offline" });
149
+ }
150
+ });
151
+ });
152
+ req.on("timeout", () => req.destroy(new Error("timeout")));
153
+ req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
154
+ req.write(payload);
155
+ req.end();
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Read EXCEPTD_OSV_FIXTURE and return an array of OSV records. Accepts
161
+ * either a single object or an array on disk.
162
+ */
163
+ function readFixture() {
164
+ const fp = process.env.EXCEPTD_OSV_FIXTURE;
165
+ if (!fp) return null;
166
+ const raw = JSON.parse(fs.readFileSync(fp, "utf8"));
167
+ return Array.isArray(raw) ? raw : [raw];
168
+ }
169
+
170
+ /**
171
+ * Fetch a single OSV record by id (MAL-*, GHSA-*, SNYK-*, RUSTSEC-*, etc.).
172
+ *
173
+ * Returns shape matches source-ghsa.fetchAdvisoryById:
174
+ * { ok: true, advisories: [<osv_record>], source: "osv-api" | "fixture" }
175
+ * { ok: false, error, source: "offline" | "fixture" }
176
+ */
177
+ async function fetchAdvisoryById(id, opts = {}) {
178
+ if (!id || typeof id !== "string") {
179
+ return { ok: false, error: "id is required (MAL-*, GHSA-*, SNYK-*, etc.)", source: "offline" };
180
+ }
181
+ const fixture = readFixture();
182
+ if (fixture) {
183
+ const want = id.toUpperCase();
184
+ const match = fixture.find((rec) => {
185
+ const recId = (rec && rec.id) ? String(rec.id).toUpperCase() : null;
186
+ if (recId === want) return true;
187
+ const aliases = Array.isArray(rec?.aliases) ? rec.aliases.map((a) => String(a).toUpperCase()) : [];
188
+ return aliases.includes(want);
189
+ });
190
+ if (!match) return { ok: false, error: `${id} not in fixture`, source: "fixture" };
191
+ return { ok: true, advisories: [match], source: "fixture" };
192
+ }
193
+ const result = await osvGet(`/v1/vulns/${encodeURIComponent(id)}`, opts.timeoutMs);
194
+ if (!result.ok) return result;
195
+ return { ok: true, advisories: [result.record], source: "osv-api" };
196
+ }
197
+
198
+ /**
199
+ * List advisories for a package, optionally filtered to a specific version.
200
+ * v0.12.10 ships the network path; bulk-import callers are a v0.13 follow-up.
201
+ */
202
+ async function fetchAdvisoriesForPackage(name, ecosystem, version, opts = {}) {
203
+ if (!name || !ecosystem) {
204
+ return { ok: false, error: "name and ecosystem are required", source: "offline" };
205
+ }
206
+ const fixture = readFixture();
207
+ if (fixture) {
208
+ // Best-effort fixture filtering: match any record whose `affected[]`
209
+ // contains the requested package + ecosystem (+ version when set).
210
+ const matches = fixture.filter((rec) => {
211
+ const affected = Array.isArray(rec?.affected) ? rec.affected : [];
212
+ return affected.some((a) => {
213
+ const pkg = a?.package || {};
214
+ if ((pkg.name || "").toLowerCase() !== name.toLowerCase()) return false;
215
+ if ((pkg.ecosystem || "").toLowerCase() !== ecosystem.toLowerCase()) return false;
216
+ if (!version) return true;
217
+ const versions = Array.isArray(a.versions) ? a.versions : [];
218
+ return versions.includes(version);
219
+ });
220
+ });
221
+ return { ok: true, advisories: matches, source: "fixture" };
222
+ }
223
+ const body = { package: { name, ecosystem } };
224
+ if (version) body.version = version;
225
+ const r = await osvPost("/v1/query", body, opts.timeoutMs);
226
+ if (!r.ok) return r;
227
+ const vulns = Array.isArray(r.record?.vulns) ? r.record.vulns : [];
228
+ return { ok: true, advisories: vulns, source: "osv-api" };
229
+ }
230
+
231
+ /**
232
+ * Pick the catalog key for an OSV record. If `aliases` contains a CVE-*
233
+ * value, prefer that (preserving the existing CVE-keyed convention).
234
+ * Otherwise return the OSV id verbatim — MAL-*, SNYK-*, RUSTSEC-*, etc.
235
+ */
236
+ function pickCatalogKey(rec) {
237
+ if (!rec || !rec.id) return null;
238
+ const aliases = Array.isArray(rec.aliases) ? rec.aliases : [];
239
+ const cve = aliases.find((a) => /^CVE-\d{4}-\d+$/i.test(String(a)));
240
+ return cve ? String(cve).toUpperCase() : String(rec.id);
241
+ }
242
+
243
+ /**
244
+ * Pull a numeric CVSS score out of an OSV severity[] entry (CVSS v3 / v4
245
+ * vector strings start with "CVSS:3.x/" or "CVSS:4.0/"). Returns null if
246
+ * no parseable score is present.
247
+ */
248
+ function extractCvss(rec) {
249
+ const sev = Array.isArray(rec?.severity) ? rec.severity : [];
250
+ let score = null;
251
+ let vector = null;
252
+ for (const s of sev) {
253
+ if (typeof s?.score !== "string") continue;
254
+ const v = s.score.trim();
255
+ // Bare numeric score
256
+ const num = parseFloat(v);
257
+ if (!Number.isNaN(num) && num >= 0 && num <= 10 && !v.includes("/")) {
258
+ if (score == null) score = num;
259
+ continue;
260
+ }
261
+ // CVSS vector — accept the highest-version vector we see.
262
+ if (/^CVSS:[34]/.test(v)) {
263
+ vector = v;
264
+ // Try to parse the score out of the trailing fragment if encoded
265
+ // as "CVSS:3.1/AV:.../9.3" — most OSV records don't embed it here,
266
+ // but some Snyk-imported records do.
267
+ const m = v.match(/\/(\d+(?:\.\d+)?)$/);
268
+ if (m && score == null) {
269
+ const candidate = parseFloat(m[1]);
270
+ if (candidate >= 0 && candidate <= 10) score = candidate;
271
+ }
272
+ }
273
+ }
274
+ return { score, vector };
275
+ }
276
+
277
+ /**
278
+ * Coarse package-ecosystem inference for the catalog `type` field. Mirrors
279
+ * the same heuristic used by source-ghsa.
280
+ */
281
+ function inferType(rec) {
282
+ const ecos = new Set();
283
+ for (const a of (rec?.affected || [])) {
284
+ if (a?.package?.ecosystem) ecos.add(String(a.package.ecosystem).toLowerCase());
285
+ }
286
+ if (ecos.has("pypi") || ecos.has("pip")) return "supply-chain-pypi";
287
+ if (ecos.has("npm")) return "supply-chain-npm";
288
+ if (ecos.has("maven")) return "supply-chain-maven";
289
+ if (ecos.has("rubygems")) return "supply-chain-gem";
290
+ if (ecos.has("crates.io") || ecos.has("cargo")) return "supply-chain-rust";
291
+ if (ecos.has("go")) return "supply-chain-go";
292
+ if (ecos.has("nuget")) return "supply-chain-nuget";
293
+ if (ecos.has("packagist")) return "supply-chain-composer";
294
+ return "supply-chain-other";
295
+ }
296
+
297
+ /**
298
+ * Normalize an OSV record into the exceptd catalog draft shape. Returns
299
+ * `{ [catalogKey]: <draft-entry> }` so callers can spread it into the
300
+ * catalog object directly. Returns null if the record is unusable.
301
+ *
302
+ * Editorial fields (framework_control_gaps, atlas_refs, attack_refs,
303
+ * rwep_factors) are left null — the seven-phase playbook flow or a human
304
+ * reviewer fills these in. `_auto_imported: true` + `_draft: true` flags
305
+ * mark the entry for the strict catalog validator (warn, not error).
306
+ */
307
+ function normalizeAdvisory(rec) {
308
+ if (!rec || !rec.id) return null;
309
+ const catalogKey = pickCatalogKey(rec);
310
+ if (!catalogKey) return null;
311
+
312
+ const aliases = Array.isArray(rec.aliases) ? rec.aliases.slice() : [];
313
+ // If the catalog key came from aliases (CVE-*), put the OSV id back into
314
+ // the aliases array so it stays discoverable.
315
+ if (catalogKey !== rec.id && !aliases.includes(rec.id)) aliases.push(rec.id);
316
+
317
+ const { score, vector } = extractCvss(rec);
318
+
319
+ const affectedPackages = [];
320
+ const affectedVersions = [];
321
+ for (const a of (rec.affected || [])) {
322
+ const pkg = a?.package || {};
323
+ if (pkg.name && pkg.ecosystem) {
324
+ affectedPackages.push(`${pkg.ecosystem}:${pkg.name}`);
325
+ }
326
+ const versions = Array.isArray(a.versions) ? a.versions : [];
327
+ for (const v of versions) {
328
+ affectedVersions.push(`${pkg.name || "?"} == ${v}`);
329
+ }
330
+ // Range bounds: surface "introduced/fixed" pairs as a textual range.
331
+ const ranges = Array.isArray(a.ranges) ? a.ranges : [];
332
+ for (const r of ranges) {
333
+ const events = Array.isArray(r.events) ? r.events : [];
334
+ const intro = events.find((e) => e.introduced)?.introduced;
335
+ const fixed = events.find((e) => e.fixed)?.fixed;
336
+ if (intro || fixed) {
337
+ affectedVersions.push(`${pkg.name || "?"} >= ${intro || "0"}` + (fixed ? `, < ${fixed}` : ""));
338
+ }
339
+ }
340
+ }
341
+
342
+ // IoC seeding from database_specific.iocs if present (some Snyk + StepSec
343
+ // imported records carry this). Domains + URLs land in c2_indicators so
344
+ // an operator scanning a repo has something to grep for immediately.
345
+ const dsIocs = rec?.database_specific?.iocs || null;
346
+ let iocs = null;
347
+ if (dsIocs && (Array.isArray(dsIocs.domains) || Array.isArray(dsIocs.urls))) {
348
+ const c2 = [];
349
+ if (Array.isArray(dsIocs.domains)) c2.push(...dsIocs.domains.map((d) => `domain: ${d}`));
350
+ if (Array.isArray(dsIocs.urls)) c2.push(...dsIocs.urls.map((u) => `url: ${u}`));
351
+ iocs = { c2_indicators: c2 };
352
+ }
353
+
354
+ // Reference URLs — OSV `references` is `[{ type, url }, ...]`.
355
+ const refUrls = [];
356
+ for (const r of (rec.references || [])) {
357
+ if (r && typeof r.url === "string") refUrls.push(r.url);
358
+ }
359
+
360
+ // Severity wording from CVSS / qualitative hint.
361
+ const severityWord = score != null && score >= 9.0 ? "critical"
362
+ : score != null && score >= 7.0 ? "high"
363
+ : score != null && score >= 4.0 ? "medium"
364
+ : score != null ? "low"
365
+ : null;
366
+
367
+ const pending = severityWord === "critical" || (score != null && score >= 9.0);
368
+
369
+ const today = new Date().toISOString().slice(0, 10);
370
+ const published = (rec.published || "").slice(0, 10) || null;
371
+ const modified = (rec.modified || "").slice(0, 10) || null;
372
+
373
+ // OSV.dev canonical advisory URL — used as the primary vendor advisory.
374
+ const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
375
+
376
+ return {
377
+ [catalogKey]: {
378
+ name: rec.summary || rec.id,
379
+ type: inferType(rec),
380
+ cvss_score: score,
381
+ cvss_vector: vector,
382
+ cisa_kev: false,
383
+ cisa_kev_date: null,
384
+ cisa_kev_pending: pending,
385
+ cisa_kev_pending_reason: pending
386
+ ? `OSV severity critical (CVSS ${score}). KEV listing typically follows for critical advisories with confirmed exploitation; verify before publish.`
387
+ : null,
388
+ poc_available: null,
389
+ poc_description: null,
390
+ ai_discovered: null,
391
+ ai_assisted_weaponization: null,
392
+ active_exploitation: severityWord === "critical" ? "suspected" : "unknown",
393
+ affected: affectedPackages.join(", ") || null,
394
+ affected_versions: affectedVersions,
395
+ vector: null,
396
+ complexity: null,
397
+ patch_available: null,
398
+ patch_required_reboot: false,
399
+ live_patch_available: null,
400
+ live_patch_tools: [],
401
+ framework_control_gaps: null,
402
+ atlas_refs: [],
403
+ attack_refs: [],
404
+ rwep_score: null,
405
+ rwep_factors: null,
406
+ rwep_notes: "Auto-imported from OSV.dev. RWEP factors require editorial review before this entry passes the strict catalog gate.",
407
+ epss_score: null,
408
+ epss_percentile: null,
409
+ epss_date: null,
410
+ epss_source: /^CVE-/i.test(catalogKey)
411
+ ? `https://api.first.org/data/v1/epss?cve=${catalogKey}`
412
+ : null,
413
+ source_verified: published || today,
414
+ verification_sources: [
415
+ osvUrl,
416
+ ...(/^CVE-/i.test(catalogKey) ? [`https://nvd.nist.gov/vuln/detail/${catalogKey}`] : []),
417
+ ...refUrls.slice(0, 10),
418
+ ],
419
+ vendor_advisories: [
420
+ {
421
+ vendor: "OSV.dev",
422
+ advisory_id: rec.id,
423
+ url: osvUrl,
424
+ severity: severityWord,
425
+ published_date: published,
426
+ },
427
+ ],
428
+ iocs,
429
+ aliases,
430
+ _auto_imported: true,
431
+ _draft: true,
432
+ _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.",
433
+ _source_osv_id: rec.id,
434
+ _source_published_at: rec.published || null,
435
+ last_updated: modified || today,
436
+ },
437
+ };
438
+ }
439
+
440
+ /**
441
+ * Build a refresh diff for the refresh-external orchestrator. v0.12.10
442
+ * supports targeted seeding: when `ctx.osv_ids` is populated, fetch each
443
+ * id and emit one `_new_entry` diff per record that isn't already in the
444
+ * local catalog. The broader package-watchlist path (bulk import from
445
+ * a watched-packages list) is deferred to v0.13.
446
+ */
447
+ async function buildDiff(ctx) {
448
+ const ids = Array.isArray(ctx?.osv_ids) ? ctx.osv_ids : [];
449
+ if (ids.length === 0) {
450
+ return {
451
+ status: "ok",
452
+ diffs: [],
453
+ errors: 0,
454
+ summary: "OSV: no ids requested (set ctx.osv_ids to seed a draft, or pass --advisory <MAL-...> for one-shot import).",
455
+ };
456
+ }
457
+ const existingKeys = new Set(Object.keys(ctx.cveCatalog || {}));
458
+ const diffs = [];
459
+ let errors = 0;
460
+ for (const id of ids) {
461
+ const r = await fetchAdvisoryById(id);
462
+ if (!r.ok) { errors++; continue; }
463
+ const rec = r.advisories[0];
464
+ if (!rec) { errors++; continue; }
465
+ const normalized = normalizeAdvisory(rec);
466
+ if (!normalized) { errors++; continue; }
467
+ const key = Object.keys(normalized)[0];
468
+ if (existingKeys.has(key)) continue;
469
+ diffs.push({
470
+ id: key,
471
+ field: "_new_entry",
472
+ before: null,
473
+ after: normalized[key],
474
+ severity: normalized[key].cvss_score != null && normalized[key].cvss_score >= 9.0 ? "critical" : null,
475
+ source: "osv",
476
+ });
477
+ }
478
+ return {
479
+ status: errors === 0 ? "ok" : errors === ids.length ? "unreachable" : "partial",
480
+ diffs,
481
+ errors,
482
+ summary: `OSV fetched ${ids.length} id(s); ${diffs.length} new entry diff(s), ${errors} failure(s).`,
483
+ };
484
+ }
485
+
486
+ module.exports = {
487
+ fetchAdvisoryById,
488
+ fetchAdvisoriesForPackage,
489
+ normalizeAdvisory,
490
+ buildDiff,
491
+ isOsvId,
492
+ OSV_ID_PREFIXES,
493
+ };
@@ -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-13T17:30:20.755Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [