@blamejs/exceptd-skills 0.12.8 → 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.
- package/AGENTS.md +2 -2
- package/ARCHITECTURE.md +21 -5
- package/CHANGELOG.md +120 -0
- package/README.md +1 -1
- package/bin/exceptd.js +227 -17
- package/data/_indexes/_meta.json +20 -20
- package/data/_indexes/activity-feed.json +17 -17
- package/data/_indexes/catalog-summaries.json +5 -5
- package/data/_indexes/chains.json +90 -11
- package/data/_indexes/frequency.json +2 -0
- package/data/_indexes/section-offsets.json +463 -355
- package/data/_indexes/token-budget.json +113 -53
- package/data/cve-catalog.json +385 -23
- package/data/cwe-catalog.json +34 -0
- package/data/playbooks/library-author.json +14 -0
- package/data/playbooks/mcp.json +1 -0
- package/data/zeroday-lessons.json +223 -1
- package/lib/playbook-runner.js +119 -35
- package/lib/prefetch.js +27 -6
- package/lib/refresh-external.js +81 -18
- package/lib/source-osv.js +493 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +51 -51
- package/orchestrator/index.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +27 -6
- package/scripts/predeploy.js +7 -9
- package/skills/ai-attack-surface/skill.md +25 -0
- package/skills/ai-c2-detection/skill.md +24 -0
- package/skills/compliance-theater/skill.md +6 -0
- package/skills/exploit-scoring/skill.md +6 -0
- package/skills/mcp-agent-trust/skill.md +24 -0
- package/skills/policy-exception-gen/skill.md +6 -0
- package/skills/rag-pipeline-security/skill.md +28 -2
- package/skills/researcher/skill.md +6 -0
- package/skills/security-maturity-tiers/skill.md +6 -0
- package/skills/skill-update-loop/skill.md +6 -0
- package/skills/threat-model-currency/skill.md +4 -0
- package/skills/zeroday-gap-learn/skill.md +6 -0
package/lib/refresh-external.js
CHANGED
|
@@ -118,21 +118,25 @@ Modes:
|
|
|
118
118
|
--from-cache [<p>] read from prefetch cache (default .cache/upstream).
|
|
119
119
|
Combine with --apply to upsert against cached data
|
|
120
120
|
entirely offline. Cache must be pre-populated via --prefetch.
|
|
121
|
-
--source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
|
|
121
|
+
--source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins|ghsa|osv)
|
|
122
122
|
--from-fixture <p> use frozen fixture payloads (tests use this path)
|
|
123
123
|
--indexes-only rebuild data/_indexes/ only; no network. Equivalent to
|
|
124
124
|
\`exceptd refresh --indexes-only\`.
|
|
125
125
|
--swarm fan out sources across worker threads. Best with --from-cache.
|
|
126
|
-
--advisory <id> (v0.12.0) seed a single catalog entry from
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
(
|
|
131
|
-
|
|
126
|
+
--advisory <id> (v0.12.0) seed a single catalog entry from an advisory ID.
|
|
127
|
+
CVE-* and GHSA-* route through the GitHub Advisory
|
|
128
|
+
Database. MAL-*, SNYK-*, RUSTSEC-*, USN-*, UVI-*, GO-*,
|
|
129
|
+
MGASA-*, PYSEC-*, and other OSV-native namespaces route
|
|
130
|
+
through OSV.dev (v0.12.10). Writes a DRAFT to
|
|
131
|
+
data/cve-catalog.json marked with _auto_imported: true.
|
|
132
|
+
Editorial fields (framework_control_gaps, iocs,
|
|
133
|
+
atlas_refs, attack_refs) remain null pending review via:
|
|
132
134
|
exceptd run cve-curation --advisory <id>
|
|
133
135
|
Examples:
|
|
134
136
|
exceptd refresh --advisory CVE-2026-45321
|
|
135
137
|
exceptd refresh --advisory GHSA-xxxx-xxxx-xxxx --apply
|
|
138
|
+
exceptd refresh --advisory MAL-2026-3083
|
|
139
|
+
exceptd refresh --advisory RUSTSEC-2025-0001
|
|
136
140
|
|
|
137
141
|
Sources (default = all):
|
|
138
142
|
kev CISA Known Exploited Vulnerabilities
|
|
@@ -143,6 +147,10 @@ Sources (default = all):
|
|
|
143
147
|
ghsa (v0.12.0) GitHub Advisory Database — npm/PyPI/Maven/etc. Lands new CVE
|
|
144
148
|
IDs as DRAFTS (_auto_imported: true); catalog validator treats drafts
|
|
145
149
|
as warnings, not errors. Editorial review still required.
|
|
150
|
+
osv (v0.12.10) OSV.dev aggregator — OSSF Malicious Packages (MAL-*) + Snyk
|
|
151
|
+
+ GHSA + RustSec + Mageia + Go Vuln DB + Ubuntu USN. Unauthenticated.
|
|
152
|
+
Use --advisory MAL-* / RUSTSEC-* / SNYK-* / USN-* to seed a single
|
|
153
|
+
draft. Bulk import via package watchlist is a v0.13 follow-up.
|
|
146
154
|
|
|
147
155
|
Air-gap workflow:
|
|
148
156
|
1. On a connected host: \`exceptd refresh --prefetch\`
|
|
@@ -526,6 +534,48 @@ const GHSA_SOURCE = {
|
|
|
526
534
|
},
|
|
527
535
|
};
|
|
528
536
|
|
|
537
|
+
/**
|
|
538
|
+
* v0.12.10: OSV.dev source. Aggregates OSSF Malicious Packages (MAL-*) +
|
|
539
|
+
* Snyk (SNYK-*) + GitHub Advisory Database + RustSec (RUSTSEC-*) + Mageia
|
|
540
|
+
* + Go Vuln DB + Ubuntu USN into one unauthenticated API. Slot in for the
|
|
541
|
+
* package-compromise class that doesn't have a CVE yet — the MAL-*
|
|
542
|
+
* namespace is the canonical key for those (e.g. MAL-2026-3083, the
|
|
543
|
+
* elementary-data PyPI worm).
|
|
544
|
+
*
|
|
545
|
+
* Apply path mirrors GHSA: new entries land in data/cve-catalog.json as
|
|
546
|
+
* drafts (`_auto_imported: true` + `_draft: true`). Catalog key is either
|
|
547
|
+
* the CVE alias (when present) or the OSV id verbatim — preserving the
|
|
548
|
+
* existing CVE-keyed convention while accepting OSV's broader identifier
|
|
549
|
+
* shapes.
|
|
550
|
+
*/
|
|
551
|
+
const OSV_SOURCE = {
|
|
552
|
+
name: "osv",
|
|
553
|
+
description: "OSV.dev — OSSF Malicious Packages (MAL-*) + Snyk + GHSA + RustSec + Mageia + Go Vuln DB + Ubuntu USN. Unauthenticated. Slot in for the broader supply-chain-class disclosure space — covers package compromises that don't have CVEs yet.",
|
|
554
|
+
applies_to: "data/cve-catalog.json",
|
|
555
|
+
async fetchDiff(ctx) {
|
|
556
|
+
if (ctx.fixtures?.osv) return synthesizeFromFixture(ctx, "osv");
|
|
557
|
+
const osv = require("./source-osv");
|
|
558
|
+
return osv.buildDiff(ctx);
|
|
559
|
+
},
|
|
560
|
+
async applyDiff(ctx, diffs) {
|
|
561
|
+
// Same shape as GHSA applyDiff — skip overwrites, surface conflicts.
|
|
562
|
+
let updated = 0;
|
|
563
|
+
const errors = [];
|
|
564
|
+
for (const d of diffs) {
|
|
565
|
+
if (d.field !== "_new_entry") continue;
|
|
566
|
+
if (!d.after || !d.id) continue;
|
|
567
|
+
if (ctx.cveCatalog[d.id]) continue; // never overwrite existing entries
|
|
568
|
+
try {
|
|
569
|
+
ctx.cveCatalog[d.id] = d.after;
|
|
570
|
+
updated++;
|
|
571
|
+
} catch (e) {
|
|
572
|
+
errors.push(`${d.id}: ${e.message}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return { updated, errors };
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
|
|
529
579
|
const ALL_SOURCES = {
|
|
530
580
|
kev: KEV_SOURCE,
|
|
531
581
|
epss: EPSS_SOURCE,
|
|
@@ -533,6 +583,7 @@ const ALL_SOURCES = {
|
|
|
533
583
|
rfc: RFC_SOURCE,
|
|
534
584
|
pins: PINS_SOURCE,
|
|
535
585
|
ghsa: GHSA_SOURCE,
|
|
586
|
+
osv: OSV_SOURCE,
|
|
536
587
|
};
|
|
537
588
|
|
|
538
589
|
// --- Cache-mode helpers ------------------------------------------------
|
|
@@ -669,17 +720,20 @@ function rfcDiffFromCache(ctx) {
|
|
|
669
720
|
|
|
670
721
|
function pinsDiffFromCache(ctx) {
|
|
671
722
|
// Cache layout under pins/: <owner>__<repo>__releases.json arrays.
|
|
723
|
+
// Only repos that publish via GitHub Releases live here — D3FEND and CWE
|
|
724
|
+
// were removed in the same pass that pruned them from lib/prefetch.js's
|
|
725
|
+
// SOURCES.pins (neither project tags releases on GitHub; D3FEND ships
|
|
726
|
+
// the ontology from d3fend/d3fend-ontology without tagged releases,
|
|
727
|
+
// and CWE distributes XML from cwe.mitre.org). Pin currency for those
|
|
728
|
+
// two frameworks is monitored via lib/upstream-check.js against their
|
|
729
|
+
// canonical mitre.org endpoints, not through the prefetch cache.
|
|
672
730
|
const PIN_REPOS = {
|
|
673
731
|
atlas_version: "mitre-atlas__atlas-data__releases",
|
|
674
732
|
attack_version: "mitre-attack__attack-stix-data__releases",
|
|
675
|
-
d3fend_version: "d3fend__d3fend-data__releases",
|
|
676
|
-
cwe_version: "mitre__cwe__releases",
|
|
677
733
|
};
|
|
678
734
|
const localOf = {
|
|
679
735
|
atlas_version: ctx.manifest.atlas_version,
|
|
680
736
|
attack_version: ctx.manifest.attack_version,
|
|
681
|
-
d3fend_version: ctx.d3fendCatalog?._meta?.version || ctx.d3fendCatalog?._meta?.d3fend_version || null,
|
|
682
|
-
cwe_version: ctx.cweCatalog?._meta?.version || ctx.cweCatalog?._meta?.cwe_version || null,
|
|
683
737
|
};
|
|
684
738
|
const diffs = [];
|
|
685
739
|
let errors = 0;
|
|
@@ -743,7 +797,7 @@ function loadCtx(opts) {
|
|
|
743
797
|
cacheDir: null,
|
|
744
798
|
};
|
|
745
799
|
if (opts.fromFixture) {
|
|
746
|
-
ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true };
|
|
800
|
+
ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true, osv: true };
|
|
747
801
|
} else if (opts.fromCache) {
|
|
748
802
|
const abs = path.resolve(opts.fromCache);
|
|
749
803
|
ctx.cacheDir = abs;
|
|
@@ -796,11 +850,20 @@ function chosenSources(opts) {
|
|
|
796
850
|
* when a draft is produced, signaling that editorial review is needed.
|
|
797
851
|
*/
|
|
798
852
|
async function seedSingleAdvisory(opts) {
|
|
799
|
-
const ghsa = require("./source-ghsa");
|
|
800
853
|
const id = opts.advisory;
|
|
801
|
-
|
|
854
|
+
// v0.12.10: route OSV-native ids (MAL-*, SNYK-*, RUSTSEC-*, USN-*, etc.)
|
|
855
|
+
// through source-osv. CVE-* and GHSA-* keep routing through GHSA because
|
|
856
|
+
// GHSA carries richer field coverage for those identifier shapes.
|
|
857
|
+
const osvMod = require("./source-osv");
|
|
858
|
+
const useOsv = osvMod.isOsvId(id) && !/^GHSA-/i.test(id);
|
|
859
|
+
const ghsa = require("./source-ghsa");
|
|
860
|
+
const sourceMod = useOsv ? osvMod : ghsa;
|
|
861
|
+
const sourceName = useOsv ? "osv" : "ghsa";
|
|
862
|
+
const fixtureEnv = useOsv ? "EXCEPTD_OSV_FIXTURE" : "EXCEPTD_GHSA_FIXTURE";
|
|
863
|
+
|
|
864
|
+
const result = await sourceMod.fetchAdvisoryById(id, {});
|
|
802
865
|
if (!result.ok) {
|
|
803
|
-
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: ${result.error}`, source: result.source, hint:
|
|
866
|
+
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: ${result.error}`, source: result.source, routed_to: sourceName, hint: `Verify the ID format (CVE-YYYY-NNNN, GHSA-*, MAL-*, SNYK-*, RUSTSEC-*, USN-*, etc.) and network reachability. Set ${fixtureEnv} for offline testing.` };
|
|
804
867
|
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
805
868
|
else process.stderr.write(`[refresh --advisory] ${err.error}\n hint: ${err.hint}\n`);
|
|
806
869
|
process.exitCode = 2;
|
|
@@ -808,15 +871,15 @@ async function seedSingleAdvisory(opts) {
|
|
|
808
871
|
}
|
|
809
872
|
const advisory = result.advisories[0];
|
|
810
873
|
if (!advisory) {
|
|
811
|
-
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source };
|
|
874
|
+
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source, routed_to: sourceName };
|
|
812
875
|
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
813
876
|
else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
|
|
814
877
|
process.exitCode = 2;
|
|
815
878
|
return;
|
|
816
879
|
}
|
|
817
|
-
const normalized =
|
|
880
|
+
const normalized = sourceMod.normalizeAdvisory(advisory);
|
|
818
881
|
if (!normalized) {
|
|
819
|
-
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory
|
|
882
|
+
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory could not be normalized (missing required fields)`, routed_to: sourceName, source_id: advisory.ghsa_id || advisory.id || null };
|
|
820
883
|
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
821
884
|
else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
|
|
822
885
|
process.exitCode = 2;
|
|
@@ -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
|
+
};
|
package/manifest-snapshot.json
CHANGED
|
@@ -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-
|
|
3
|
+
"_generated_at": "2026-05-13T17:30:20.755Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|