@blamejs/exceptd-skills 0.13.126 → 0.14.1
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 +5 -3
- package/CHANGELOG.md +30 -0
- package/README.md +43 -9
- package/bin/exceptd.js +148 -35
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/citation-hygiene.json +820 -0
- package/lib/citation-resolve.js +226 -0
- package/lib/collectors/cicd-pipeline-compromise.js +10 -1
- package/lib/collectors/citation-hygiene.js +465 -0
- package/lib/collectors/containers.js +12 -7
- package/lib/collectors/crypto-codebase.js +11 -5
- package/lib/collectors/library-author.js +82 -10
- package/lib/collectors/scan-excludes.js +85 -0
- package/lib/collectors/secrets.js +10 -6
- package/lib/cve-cli.js +51 -0
- package/lib/flag-suggest.js +2 -2
- package/lib/refresh-external.js +15 -0
- package/lib/rfc-cli.js +68 -0
- package/lib/schemas/cve-catalog.schema.json +13 -0
- package/lib/source-ghsa.js +3 -0
- package/lib/source-osv.js +4 -0
- package/lib/validate-package.js +7 -2
- package/manifest.json +44 -44
- package/package.json +1 -1
- package/sbom.cdx.json +134 -44
- package/scripts/check-agents-md-collectors.js +8 -0
- package/sources/validators/cve-validator.js +46 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/citation-resolve.js
|
|
5
|
+
*
|
|
6
|
+
* Answers "is this CVE/RFC citation valid?" so an agent gets the answer FROM
|
|
7
|
+
* exceptd instead of researching each citation against NVD / the IETF
|
|
8
|
+
* datatracker by hand. Offline-first:
|
|
9
|
+
*
|
|
10
|
+
* CVE: local catalog -> resolved cache -> (opt-in) one NVD lookup, cached.
|
|
11
|
+
* RFC: local index -> resolved cache -> (opt-in) one datatracker lookup.
|
|
12
|
+
*
|
|
13
|
+
* The resolved cache lives at .cache/upstream/resolved/<kind>/<id>.json with a
|
|
14
|
+
* 7-day TTL. The FIRST agent to resolve an uncatalogued id pays one network
|
|
15
|
+
* call and writes the cache; sibling agents (and later offline runs) read it —
|
|
16
|
+
* turning N agents x M citations of redundant lookups into one lookup per id.
|
|
17
|
+
*
|
|
18
|
+
* Network is opt-out: --air-gap / EXCEPTD_AIR_GAP=1 / { noNetwork:true } make
|
|
19
|
+
* resolution offline-only (catalog + cache), returning status "unknown" with a
|
|
20
|
+
* reason rather than reaching out. Network-resolved records are transient
|
|
21
|
+
* (cache only) and are never written into the signed catalog.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const path = require("node:path");
|
|
26
|
+
|
|
27
|
+
const PKG_ROOT = path.join(__dirname, "..");
|
|
28
|
+
const CVE_CATALOG = process.env.EXCEPTD_CVE_CATALOG || path.join(PKG_ROOT, "data", "cve-catalog.json");
|
|
29
|
+
const RFC_INDEX = process.env.EXCEPTD_RFC_INDEX || path.join(PKG_ROOT, "data", "rfc-references.json");
|
|
30
|
+
const RESOLVE_CACHE_DIR = process.env.EXCEPTD_RESOLVE_CACHE_DIR || path.join(PKG_ROOT, ".cache", "upstream", "resolved");
|
|
31
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // matches the prefetch freshness window
|
|
32
|
+
|
|
33
|
+
const CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
34
|
+
const RFC_RE = /^(?:RFC[-\s]?)?(\d+)$/i;
|
|
35
|
+
|
|
36
|
+
let _cve = null;
|
|
37
|
+
let _rfc = null;
|
|
38
|
+
function cveCatalog() {
|
|
39
|
+
if (!_cve) _cve = JSON.parse(fs.readFileSync(CVE_CATALOG, "utf8"));
|
|
40
|
+
return _cve;
|
|
41
|
+
}
|
|
42
|
+
function rfcIndex() {
|
|
43
|
+
if (!_rfc) _rfc = JSON.parse(fs.readFileSync(RFC_INDEX, "utf8"));
|
|
44
|
+
return _rfc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- resolved-id cache (atomic JSON files, TTL-bounded, best-effort) ---
|
|
48
|
+
function cachePath(kind, id) {
|
|
49
|
+
// Read the env at call time so tests can isolate the cache per-case.
|
|
50
|
+
const dir = process.env.EXCEPTD_RESOLVE_CACHE_DIR || RESOLVE_CACHE_DIR;
|
|
51
|
+
const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
52
|
+
return path.join(dir, kind, `${safe}.json`);
|
|
53
|
+
}
|
|
54
|
+
function cacheGet(kind, id) {
|
|
55
|
+
try {
|
|
56
|
+
const p = cachePath(kind, id);
|
|
57
|
+
const st = fs.statSync(p);
|
|
58
|
+
if (Date.now() - st.mtimeMs > CACHE_TTL_MS) return null;
|
|
59
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
60
|
+
} catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
function cachePut(kind, id, record) {
|
|
63
|
+
try {
|
|
64
|
+
const p = cachePath(kind, id);
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(record));
|
|
68
|
+
fs.renameSync(tmp, p); // atomic — concurrent agents can't read a half-written file
|
|
69
|
+
} catch { /* cache is an optimization, never fatal */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isAirGap(opts) {
|
|
73
|
+
return !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a CVE citation. Returns { id, kind:"cve", status, from, ... }.
|
|
78
|
+
* status: published | rejected | disputed | fabricated | nonexistent | unknown
|
|
79
|
+
* from: format | catalog | cache | network | offline | error
|
|
80
|
+
*/
|
|
81
|
+
async function resolveCve(id, opts = {}) {
|
|
82
|
+
const cveId = String(id || "").toUpperCase();
|
|
83
|
+
const base = { id: cveId, kind: "cve" };
|
|
84
|
+
|
|
85
|
+
if (!CVE_RE.test(cveId)) {
|
|
86
|
+
return { ...base, status: "fabricated", from: "format",
|
|
87
|
+
reason: "not the canonical CVE-YYYY-NNNN form — a non-numeric tail is a fabricated identifier" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 1. curated catalog (offline, authoritative for the ids it covers)
|
|
91
|
+
const entry = cveCatalog()[cveId];
|
|
92
|
+
if (entry && typeof entry === "object") {
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
status: entry.status || "published",
|
|
96
|
+
cvss: entry.cvss_score ?? null,
|
|
97
|
+
kev: entry.cisa_kev ?? null,
|
|
98
|
+
product: entry.name || entry.type || null,
|
|
99
|
+
exploitation: entry.active_exploitation ?? null,
|
|
100
|
+
from: "catalog",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. resolved cache (offline, warmed by a prior agent's lookup)
|
|
105
|
+
const cached = cacheGet("cve", cveId);
|
|
106
|
+
if (cached) return { ...cached, from: "cache" };
|
|
107
|
+
|
|
108
|
+
// 3. offline / air-gap: cannot resolve uncatalogued ids without network
|
|
109
|
+
if (isAirGap(opts)) {
|
|
110
|
+
return { ...base, status: "unknown", from: "offline",
|
|
111
|
+
reason: "air-gap: not in local catalog and no cached resolution — verify against NVD when online" };
|
|
112
|
+
}
|
|
113
|
+
if (opts.noNetwork) {
|
|
114
|
+
return { ...base, status: "unknown", from: "offline",
|
|
115
|
+
reason: "not in local catalog and no cached resolution (network disabled)" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. resolve once via NVD, then cache for sibling agents.
|
|
119
|
+
// opts._validateCve is a test seam (inject a fake validator); production uses
|
|
120
|
+
// the real NVD-backed validator.
|
|
121
|
+
let validateCve = opts._validateCve;
|
|
122
|
+
if (!validateCve) {
|
|
123
|
+
try { ({ validateCve } = require("../sources/validators/cve-validator.js")); }
|
|
124
|
+
catch { return { ...base, status: "unknown", from: "error", reason: "cve validator unavailable" }; }
|
|
125
|
+
}
|
|
126
|
+
let v;
|
|
127
|
+
try { v = await validateCve(cveId, {}); }
|
|
128
|
+
catch (e) { return { ...base, status: "unknown", from: "error", reason: e.message }; }
|
|
129
|
+
|
|
130
|
+
if (v.status === "unreachable") {
|
|
131
|
+
return { ...base, status: "unknown", from: "offline", reason: "NVD unreachable — retry online" };
|
|
132
|
+
}
|
|
133
|
+
// NVD is the authority for a CVE's existence and lifecycle. validateCve only
|
|
134
|
+
// returns "unreachable" when EVERY source fails — if NVD is down but KEV/EPSS
|
|
135
|
+
// answer, it returns match/drift with sources.nvd.reachable === false. Do NOT
|
|
136
|
+
// declare "published" on KEV/EPSS alone during an NVD outage; that would
|
|
137
|
+
// falsely validate an unconfirmed (or nonexistent) identifier.
|
|
138
|
+
const nvd = v.fetched && v.fetched.sources && v.fetched.sources.nvd;
|
|
139
|
+
if (!nvd || nvd.reachable !== true) {
|
|
140
|
+
return { ...base, status: "unknown", from: "offline",
|
|
141
|
+
reason: "NVD unreachable — CVE existence/status unconfirmed; retry online" };
|
|
142
|
+
}
|
|
143
|
+
let status;
|
|
144
|
+
if (v.status === "rejected") status = "rejected";
|
|
145
|
+
else if (v.status === "missing" || nvd.found !== true) status = "nonexistent";
|
|
146
|
+
else if ((v.fetched?.cve_tags || []).some(t => /disputed/i.test(t)) || /disputed/i.test(v.fetched?.nvd_vuln_status || "")) status = "disputed";
|
|
147
|
+
else status = "published";
|
|
148
|
+
|
|
149
|
+
const record = {
|
|
150
|
+
id: cveId, kind: "cve", status,
|
|
151
|
+
cvss: v.fetched?.cvss_score ?? null,
|
|
152
|
+
kev: v.fetched?.in_kev ?? null,
|
|
153
|
+
// NVD English description — carries the product/scope a citation must match,
|
|
154
|
+
// so an agent can confirm status=published applies to the right product
|
|
155
|
+
// without a second manual NVD lookup.
|
|
156
|
+
product: v.fetched?.description ?? null,
|
|
157
|
+
nvd_vuln_status: v.fetched?.nvd_vuln_status ?? null,
|
|
158
|
+
cve_tags: v.fetched?.cve_tags || [],
|
|
159
|
+
source: "nvd",
|
|
160
|
+
resolved_at: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
cachePut("cve", cveId, record);
|
|
163
|
+
return { ...record, from: "network" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve an RFC citation. Returns { id, kind:"rfc", number, title, rfc_status,
|
|
168
|
+
* found, from, ... }. The local index covers the whole current RFC series, so
|
|
169
|
+
* number->title resolution is fully offline. Obsoleted/historic RFCs are
|
|
170
|
+
* excluded from the index, so a not-found number is either obsoleted or
|
|
171
|
+
* nonexistent; the optional network step disambiguates.
|
|
172
|
+
*/
|
|
173
|
+
async function resolveRfc(id, opts = {}) {
|
|
174
|
+
const raw = String(id || "").trim();
|
|
175
|
+
const m = raw.match(RFC_RE);
|
|
176
|
+
const base = { id: raw, kind: "rfc" };
|
|
177
|
+
if (!m) {
|
|
178
|
+
return { ...base, found: false, status: "unknown", from: "format",
|
|
179
|
+
reason: "not an RFC number — expected `RFC <n>` or a bare number" };
|
|
180
|
+
}
|
|
181
|
+
const num = Number(m[1]);
|
|
182
|
+
const key = `RFC-${num}`;
|
|
183
|
+
|
|
184
|
+
// 1. local index (offline, whole current series)
|
|
185
|
+
const entry = rfcIndex()[key];
|
|
186
|
+
if (entry && typeof entry === "object") {
|
|
187
|
+
return {
|
|
188
|
+
...base, number: num, found: true,
|
|
189
|
+
title: entry.title || null,
|
|
190
|
+
rfc_status: entry.status || null,
|
|
191
|
+
published: entry.published || null,
|
|
192
|
+
obsoleted_by: entry.obsoleted_by || null,
|
|
193
|
+
from: "index",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. resolved cache
|
|
198
|
+
const cached = cacheGet("rfc", String(num));
|
|
199
|
+
if (cached) return { ...cached, from: "cache" };
|
|
200
|
+
|
|
201
|
+
// 3. offline: report the ambiguity rather than guessing
|
|
202
|
+
if (isAirGap(opts) || opts.noNetwork) {
|
|
203
|
+
return { ...base, number: num, found: false, status: "unknown", from: "offline",
|
|
204
|
+
reason: "not in the local RFC index — likely obsoleted/historic (excluded from the index) or nonexistent; verify at datatracker.ietf.org when online" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. disambiguate obsoleted vs nonexistent via the datatracker, once + cached
|
|
208
|
+
let validateRfc;
|
|
209
|
+
try { ({ validateRfc } = require("../sources/validators/rfc-validator.js")); }
|
|
210
|
+
catch { return { ...base, number: num, found: false, status: "unknown", from: "error", reason: "rfc validator unavailable" }; }
|
|
211
|
+
let v;
|
|
212
|
+
try { v = await validateRfc(key, {}); }
|
|
213
|
+
catch (e) { return { ...base, number: num, found: false, status: "unknown", from: "error", reason: e.message }; }
|
|
214
|
+
if (v.status === "unreachable") {
|
|
215
|
+
return { ...base, number: num, found: false, status: "unknown", from: "offline", reason: "datatracker unreachable — retry online" };
|
|
216
|
+
}
|
|
217
|
+
const record = v.status === "missing"
|
|
218
|
+
? { id: raw, kind: "rfc", number: num, found: false, status: "nonexistent", source: "datatracker", resolved_at: new Date().toISOString() }
|
|
219
|
+
: { id: raw, kind: "rfc", number: num, found: true, status: "obsoleted-or-historic",
|
|
220
|
+
title: v.fetched?.title || null, source: "datatracker", resolved_at: new Date().toISOString(),
|
|
221
|
+
note: "resolves at the datatracker but is absent from the local index (obsoleted/historic RFCs are excluded)" };
|
|
222
|
+
cachePut("rfc", String(num), record);
|
|
223
|
+
return { ...record, from: "network" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { resolveCve, resolveRfc };
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
const fs = require("node:fs");
|
|
23
23
|
const path = require("node:path");
|
|
24
|
+
const { isLinkedWorktreeDir } = require("./scan-excludes");
|
|
24
25
|
|
|
25
26
|
const COLLECTOR_ID = "cicd-pipeline-compromise";
|
|
26
27
|
|
|
@@ -219,7 +220,15 @@ function scanOidcPolicies(root) {
|
|
|
219
220
|
for (const e of entries) {
|
|
220
221
|
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
221
222
|
const full = path.join(dir, e.name);
|
|
222
|
-
if (e.isDirectory()) {
|
|
223
|
+
if (e.isDirectory()) {
|
|
224
|
+
// Skip linked git worktrees (gitdir-pointer `.git` file), e.g.
|
|
225
|
+
// agent-created repo copies under `.claude/worktrees/<id>/`
|
|
226
|
+
// nested below a scanned policy/infra dir — rescanning them
|
|
227
|
+
// double-counts the same OIDC trust documents.
|
|
228
|
+
if (isLinkedWorktreeDir(full)) continue;
|
|
229
|
+
walk(full, depth + 1);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
223
232
|
if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
|
|
224
233
|
const text = readSafe(full);
|
|
225
234
|
if (!text) continue;
|