@blamejs/exceptd-skills 0.18.7 → 0.18.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/bin/exceptd.js +103 -63
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/library-author.js +5 -2
- package/lib/playbook-runner.js +68 -10
- package/lib/prefetch.js +16 -1
- package/lib/refresh-network.js +37 -2
- package/lib/scoring.js +37 -10
- package/lib/validate-cve-catalog.js +11 -1
- package/lib/validate-indexes.js +76 -31
- package/lib/validate-playbooks.js +15 -6
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +31 -7
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +42 -42
- package/scripts/build-indexes.js +27 -8
- package/scripts/validate-vendor-online.js +13 -2
|
@@ -278,7 +278,17 @@ function additionalChecks(key, entry, ctx) {
|
|
|
278
278
|
},
|
|
279
279
|
];
|
|
280
280
|
for (const { field, set, file } of REF_FIELDS) {
|
|
281
|
-
if (!set)
|
|
281
|
+
if (!set) {
|
|
282
|
+
// A reference catalog is part of a valid tree, so its absence is not a
|
|
283
|
+
// "defense-in-depth skip" — it means the ref family CANNOT be validated,
|
|
284
|
+
// and an orphaned id would ship unchecked. Surface it (promoted to a hard
|
|
285
|
+
// error under --strict, like an unresolved ref) for any entry that
|
|
286
|
+
// actually carries the field, rather than passing silently.
|
|
287
|
+
if (entry[field] && (Array.isArray(entry[field]) ? entry[field].length > 0 : typeof entry[field] === 'object' && Object.keys(entry[field]).length > 0)) {
|
|
288
|
+
warnings.push(`${key}: cannot validate ${field} — ${file} catalog is absent, so its ids go unchecked (an error under --strict).`);
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
282
292
|
const refs = entry[field];
|
|
283
293
|
// Most ref fields are arrays of id strings (atlas_refs, cwe_refs, ...),
|
|
284
294
|
// but framework_control_gaps is a map keyed BY control id. An
|
package/lib/validate-indexes.js
CHANGED
|
@@ -79,6 +79,79 @@ function verifyOutputs(idxDir, outputs) {
|
|
|
79
79
|
return issues;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Compare a live source set against the recorded source_hashes table and return
|
|
84
|
+
* { drift, missing } message arrays. Pure (no I/O of its own beyond the injected
|
|
85
|
+
* absFn + fs reads of the named sources) so it is unit-testable for the
|
|
86
|
+
* fail-closed edge cases:
|
|
87
|
+
* - a source that vanished between discovery and hashing (TOCTOU) is REPORTED
|
|
88
|
+
* missing, never an unhandled ENOENT crash;
|
|
89
|
+
* - a non-string recorded hash (corrupted _meta.json) is REPORTED as drift,
|
|
90
|
+
* never a TypeError from .slice() of a non-string.
|
|
91
|
+
*
|
|
92
|
+
* @param {Set<string>|string[]} liveSources source paths discovered live
|
|
93
|
+
* @param {Record<string,string>} recorded the _meta.json source_hashes table
|
|
94
|
+
* @param {(p:string)=>string} absFn resolves a source path to absolute
|
|
95
|
+
* @returns {{ drift: string[], missing: string[] }}
|
|
96
|
+
*/
|
|
97
|
+
function checkSourceHashes(liveSources, recorded, absFn) {
|
|
98
|
+
const drift = [];
|
|
99
|
+
const missing = [];
|
|
100
|
+
const recordedKeys = new Set(Object.keys(recorded || {}));
|
|
101
|
+
for (const p of liveSources) {
|
|
102
|
+
if (!recordedKeys.has(p)) {
|
|
103
|
+
missing.push(`new source not in index: ${p}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const abs = absFn(p);
|
|
107
|
+
// F16 — also check listed-but-symlinked sources. A source can disappear or
|
|
108
|
+
// be swapped between discovery and hashing (TOCTOU); resolve its type and
|
|
109
|
+
// read it defensively so a vanished or newly-symlinked file is REPORTED as
|
|
110
|
+
// missing, never an unhandled ENOENT that crashes the whole validation.
|
|
111
|
+
// Open through a single O_NOFOLLOW descriptor and hash the bytes from THAT
|
|
112
|
+
// descriptor — no path stat-then-read, so there is no check-then-use window.
|
|
113
|
+
// O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; an absent
|
|
114
|
+
// file (ENOENT) is reported missing instead of crashing; the fstat type
|
|
115
|
+
// check refuses a dir/junction/fifo on every platform.
|
|
116
|
+
let live;
|
|
117
|
+
{
|
|
118
|
+
let fd;
|
|
119
|
+
try { fd = fs.openSync(abs, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); }
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (e.code === 'ENOENT') missing.push(`source file disappeared between discovery and hashing: ${p}`);
|
|
122
|
+
else if (e.code === 'ELOOP') missing.push(`source ${p} is a symbolic link — refusing to follow`);
|
|
123
|
+
else missing.push(`source ${p} could not be opened: ${e.code || e.message}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.fstatSync(fd).isFile()) {
|
|
128
|
+
missing.push(`source ${p} is not a regular file (symlink / dir / fifo) — refusing`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
live = sha256(fs.readFileSync(fd));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
missing.push(`source ${p} could not be read: ${e.code || e.message}`);
|
|
134
|
+
continue;
|
|
135
|
+
} finally { fs.closeSync(fd); }
|
|
136
|
+
}
|
|
137
|
+
const recordedHash = recorded[p];
|
|
138
|
+
if (typeof recordedHash !== 'string') {
|
|
139
|
+
// A null/non-string recorded hash means a corrupted _meta.json
|
|
140
|
+
// source_hashes map — report it as drift rather than crashing on
|
|
141
|
+
// .slice() of a non-string (fail-closed, not fail-open).
|
|
142
|
+
drift.push(`hash drift: ${p} (recorded entry is not a string: ${JSON.stringify(recordedHash)}, live ${live.slice(0, 12)}…)`);
|
|
143
|
+
} else if (live !== recordedHash) {
|
|
144
|
+
drift.push(`hash drift: ${p} (recorded ${recordedHash.slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const p of recordedKeys) {
|
|
148
|
+
if (!(liveSources instanceof Set ? liveSources.has(p) : liveSources.includes(p))) {
|
|
149
|
+
missing.push(`stale source in index (file removed): ${p}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { drift, missing };
|
|
153
|
+
}
|
|
154
|
+
|
|
82
155
|
function main() {
|
|
83
156
|
if (!fs.existsSync(META)) {
|
|
84
157
|
console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
|
|
@@ -135,35 +208,7 @@ function main() {
|
|
|
135
208
|
}
|
|
136
209
|
for (const s of manifest.skills) liveSources.add(s.path);
|
|
137
210
|
|
|
138
|
-
const drift =
|
|
139
|
-
const missing = [];
|
|
140
|
-
const recordedKeys = new Set(Object.keys(recorded));
|
|
141
|
-
|
|
142
|
-
for (const p of liveSources) {
|
|
143
|
-
if (!recordedKeys.has(p)) {
|
|
144
|
-
missing.push(`new source not in index: ${p}`);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
const abs = ABS(p);
|
|
148
|
-
// F16 — also check listed-but-symlinked sources. lstatSync on a missing
|
|
149
|
-
// file throws; mirror the existsSync semantics by guarding it.
|
|
150
|
-
if (fs.existsSync(abs)) {
|
|
151
|
-
const st = fs.lstatSync(abs);
|
|
152
|
-
if (st.isSymbolicLink()) {
|
|
153
|
-
missing.push(`source ${p} is a symbolic link — refusing to follow`);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
const live = sha256(fs.readFileSync(abs));
|
|
158
|
-
if (live !== recorded[p]) {
|
|
159
|
-
drift.push(`hash drift: ${p} (recorded ${recorded[p].slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
for (const p of recordedKeys) {
|
|
163
|
-
if (!liveSources.has(p)) {
|
|
164
|
-
missing.push(`stale source in index (file removed): ${p}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
211
|
+
const { drift, missing } = checkSourceHashes(liveSources, recorded, ABS);
|
|
167
212
|
|
|
168
213
|
// Verify the derived index OUTPUTS themselves exist and parse. Source-hash
|
|
169
214
|
// drift alone does not catch a deleted/truncated/corrupted OUTPUT: a clean
|
|
@@ -185,7 +230,7 @@ function main() {
|
|
|
185
230
|
|
|
186
231
|
const issues = [...drift, ...missing];
|
|
187
232
|
if (issues.length === 0) {
|
|
188
|
-
console.log(`[validate-indexes] indexes current — ${
|
|
233
|
+
console.log(`[validate-indexes] indexes current — ${Object.keys(recorded).length} sources hashed at ${meta.generated_at}.`);
|
|
189
234
|
return;
|
|
190
235
|
}
|
|
191
236
|
|
|
@@ -197,4 +242,4 @@ function main() {
|
|
|
197
242
|
|
|
198
243
|
if (require.main === module) main();
|
|
199
244
|
|
|
200
|
-
module.exports = { main, verifyOutputs };
|
|
245
|
+
module.exports = { main, verifyOutputs, checkSourceHashes };
|
|
@@ -248,7 +248,11 @@ function loadContext() {
|
|
|
248
248
|
const cve = readJson(CVE_PATH);
|
|
249
249
|
const cwe = readJson(CWE_PATH);
|
|
250
250
|
const d3 = readJson(D3FEND_PATH);
|
|
251
|
-
|
|
251
|
+
// Required, like the other ref catalogs (cwe / d3fend). It is committed and
|
|
252
|
+
// always present; loading it optionally let attack_ref validation skip
|
|
253
|
+
// silently when it was absent — the asymmetry that allowed an unresolvable
|
|
254
|
+
// attack_ref to ship. Its absence must fail loud.
|
|
255
|
+
const attack = readJson(ATTACK_PATH);
|
|
252
256
|
|
|
253
257
|
// Closed controlled-vocabulary enums sourced from the schema so the
|
|
254
258
|
// hard-error enum checks in checkCrossRefs stay in lockstep with the
|
|
@@ -266,11 +270,16 @@ function loadContext() {
|
|
|
266
270
|
.jurisdiction_obligations.items.properties.clock_starts.enum || null;
|
|
267
271
|
frameworksEnum =
|
|
268
272
|
schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
|
|
269
|
-
} catch {
|
|
270
|
-
//
|
|
271
|
-
// here
|
|
272
|
-
|
|
273
|
-
|
|
273
|
+
} catch (e) {
|
|
274
|
+
// The playbook schema is a committed, required file. Swallowing a read error
|
|
275
|
+
// here silently disabled the clock_starts / frameworks_in_scope closed-vocab
|
|
276
|
+
// checks, so a typo in those fields could ship. Fail loud instead.
|
|
277
|
+
throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
|
|
278
|
+
}
|
|
279
|
+
// A successful parse with the wrong shape (enum path moved/renamed) would also
|
|
280
|
+
// leave the enums null and disable the checks. Treat that as fatal too.
|
|
281
|
+
if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
|
|
282
|
+
throw new Error(`validate-playbooks: playbook schema ${SCHEMA_PATH} did not yield the clock_starts / frameworks_in_scope enums (shape changed). Refusing to validate with the closed-vocab checks silently disabled — fix the schema path expressions in loadContext().`);
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
return {
|
package/lib/verify.js
CHANGED
|
@@ -369,8 +369,11 @@ function verifyManifestSignature(manifest) {
|
|
|
369
369
|
// Previously the pin only fired at the CLI tail of `node lib/verify.js`,
|
|
370
370
|
// letting a coordinated attacker who swapped keys/public.pem authenticate
|
|
371
371
|
// against the attacker key without any divergence surfaced through the
|
|
372
|
-
// library API. Honors KEYS_ROTATED=1 for legitimate rotations
|
|
373
|
-
//
|
|
372
|
+
// library API. Honors KEYS_ROTATED=1 for legitimate rotations. A MISSING pin
|
|
373
|
+
// file is now rejected too: keys/EXPECTED_FINGERPRINT ships in the tarball and
|
|
374
|
+
// is committed to the repo, so its absence is not a legacy state — it is the
|
|
375
|
+
// signature a key-substitution attack leaves when it strips the pin to hide a
|
|
376
|
+
// swapped keys/public.pem.
|
|
374
377
|
const liveFp = publicKeyFingerprint(publicKey);
|
|
375
378
|
const pinResult = checkExpectedFingerprint(liveFp);
|
|
376
379
|
if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
|
|
@@ -382,6 +385,17 @@ function verifyManifestSignature(manifest) {
|
|
|
382
385
|
actual: pinResult.actual,
|
|
383
386
|
};
|
|
384
387
|
}
|
|
388
|
+
if (pinResult.status === 'no-pin') {
|
|
389
|
+
// A missing pin fails closed unconditionally — there is nothing to override.
|
|
390
|
+
// KEYS_ROTATED only applies to a fingerprint MISMATCH (a new key + a new
|
|
391
|
+
// pin); a legitimate rotation updates the pin in place, it never removes it,
|
|
392
|
+
// so an absent pin is treated as tampering and restoring it is the only fix.
|
|
393
|
+
return {
|
|
394
|
+
status: 'invalid',
|
|
395
|
+
reason: `key-pin absent: keys/EXPECTED_FINGERPRINT is missing, so a swapped keys/public.pem cannot be detected. The pin ships in the tarball and is committed — restore it from the package or version control.`,
|
|
396
|
+
pin_absent: true,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
385
399
|
let signatureBytes;
|
|
386
400
|
try {
|
|
387
401
|
signatureBytes = Buffer.from(sig.signature_base64, 'base64');
|
|
@@ -451,10 +465,13 @@ function loadManifestValidated() {
|
|
|
451
465
|
{ code: 'EXCEPTD_MANIFEST_UNSIGNED' }
|
|
452
466
|
);
|
|
453
467
|
} else if (sigResult.status === 'no-key') {
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
468
|
+
// The documented contract is "return a VERIFIED manifest or throw" — never
|
|
469
|
+
// an unverified one. verifyAll()/verifyOne() short-circuit on a missing key
|
|
470
|
+
// BEFORE reaching here, so this fires only for a direct
|
|
471
|
+
// loadManifestValidated() caller, which must not silently receive an
|
|
472
|
+
// unauthenticated manifest: an attacker who deletes keys/public.pem could
|
|
473
|
+
// otherwise smuggle a rewritten manifest past a library consumer.
|
|
474
|
+
throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. Cannot verify the manifest without keys/public.pem; refusing to return an unauthenticated manifest.`);
|
|
458
475
|
}
|
|
459
476
|
return manifest;
|
|
460
477
|
}
|