@blamejs/exceptd-skills 0.12.10 → 0.12.13
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 +131 -0
- package/README.md +3 -1
- package/bin/exceptd.js +152 -39
- package/data/_indexes/_meta.json +10 -9
- package/data/_indexes/activity-feed.json +11 -3
- package/data/_indexes/catalog-summaries.json +24 -2
- package/data/_indexes/frequency.json +2 -0
- package/data/attack-techniques.json +96 -0
- package/data/cve-catalog.json +9 -9
- package/data/cwe-catalog.json +4 -3
- package/data/framework-control-gaps.json +52 -0
- package/data/playbooks/library-author.json +3 -3
- package/lib/cve-curation.js +491 -46
- package/lib/lint-skills.js +212 -15
- package/lib/playbook-runner.js +485 -108
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +257 -81
- package/lib/refresh-network.js +15 -1
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +68 -5
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +7 -1
- package/lib/source-osv.js +228 -57
- package/lib/validate-cve-catalog.js +171 -3
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +241 -16
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/scheduler.js +50 -7
- package/package.json +1 -1
- package/sbom.cdx.json +8 -8
- package/scripts/predeploy.js +31 -5
package/lib/verify.js
CHANGED
|
@@ -8,6 +8,28 @@
|
|
|
8
8
|
* specific keypair signed each skill. Even if the manifest is updated, a valid
|
|
9
9
|
* signature requires the private key, which never enters this repository.
|
|
10
10
|
*
|
|
11
|
+
* Byte-stability contract (must mirror lib/sign.js):
|
|
12
|
+
* Skill content is normalized BEFORE the signature is verified:
|
|
13
|
+
* 1. Strip a UTF-8 BOM (U+FEFF) if present.
|
|
14
|
+
* 2. Convert CRLF line endings to LF.
|
|
15
|
+
* The same normalization runs in lib/sign.js. A skill file checked
|
|
16
|
+
* out with core.autocrlf=true on Windows therefore verifies against
|
|
17
|
+
* a signature produced on Linux CI (LF). ANY change to normalize()
|
|
18
|
+
* requires the matching change in lib/sign.js — round-trip stability
|
|
19
|
+
* is a hard contract. The v0.11.x signature regression (operators
|
|
20
|
+
* ran `exceptd doctor --signatures` and saw 0/38) was a single
|
|
21
|
+
* instance of this contract drifting; do not relax it.
|
|
22
|
+
*
|
|
23
|
+
* Manifest entries are validated through validateSkillPath() before
|
|
24
|
+
* any file is read. A tampered manifest with `path: "../../../etc/passwd"`
|
|
25
|
+
* cannot escape the skills/ tree. The whole manifest is rejected on
|
|
26
|
+
* the first traversal attempt.
|
|
27
|
+
*
|
|
28
|
+
* The manifest object itself is validated against
|
|
29
|
+
* lib/schemas/manifest.schema.json before any skill is touched.
|
|
30
|
+
* additionalProperties=false at the skill level catches typos and
|
|
31
|
+
* unknown fields that would otherwise silently be dropped.
|
|
32
|
+
*
|
|
11
33
|
* Signing ceremony: see lib/sign.js
|
|
12
34
|
* Public key: keys/public.pem (tracked in repo)
|
|
13
35
|
* Private key: .keys/private.pem (gitignored, kept off-repo)
|
|
@@ -28,6 +50,7 @@ const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
|
|
|
28
50
|
const SKILLS_DIR = path.join(ROOT, 'skills');
|
|
29
51
|
const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
|
|
30
52
|
const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
|
|
53
|
+
const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
|
|
31
54
|
|
|
32
55
|
// --- public API ---
|
|
33
56
|
|
|
@@ -42,7 +65,7 @@ function verifyAll() {
|
|
|
42
65
|
return { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: true };
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
const manifest =
|
|
68
|
+
const manifest = loadManifestValidated();
|
|
46
69
|
const result = { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: false };
|
|
47
70
|
|
|
48
71
|
for (const skill of manifest.skills) {
|
|
@@ -65,7 +88,7 @@ function verifyOne(skillName) {
|
|
|
65
88
|
const publicKey = loadPublicKey();
|
|
66
89
|
if (!publicKey) throw new Error('No public key at keys/public.pem');
|
|
67
90
|
|
|
68
|
-
const manifest =
|
|
91
|
+
const manifest = loadManifestValidated();
|
|
69
92
|
const skill = manifest.skills.find(s => s.name === skillName);
|
|
70
93
|
if (!skill) throw new Error(`Skill not in manifest: ${skillName}`);
|
|
71
94
|
|
|
@@ -81,7 +104,7 @@ function signAll() {
|
|
|
81
104
|
const privateKey = loadPrivateKey();
|
|
82
105
|
if (!privateKey) throw new Error('No private key at .keys/private.pem — run: node lib/sign.js generate-keypair');
|
|
83
106
|
|
|
84
|
-
const manifest =
|
|
107
|
+
const manifest = loadManifestValidated();
|
|
85
108
|
const result = { signed: [], errors: [] };
|
|
86
109
|
|
|
87
110
|
for (const skill of manifest.skills) {
|
|
@@ -104,6 +127,56 @@ function signAll() {
|
|
|
104
127
|
|
|
105
128
|
// --- private helpers ---
|
|
106
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Normalize skill content for byte-stable verification.
|
|
132
|
+
*
|
|
133
|
+
* Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
|
|
134
|
+
* line endings to LF. lib/sign.js applies the exact same transform —
|
|
135
|
+
* see the byte-stability contract in the file header.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} content
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function normalize(content) {
|
|
141
|
+
let s = content;
|
|
142
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
143
|
+
return s.replace(/\r\n/g, '\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate a manifest skill.path entry to prevent path traversal.
|
|
148
|
+
*
|
|
149
|
+
* skill.path MUST be a string.
|
|
150
|
+
* skill.path MUST start with "skills/".
|
|
151
|
+
* skill.path MUST NOT contain "..".
|
|
152
|
+
* skill.path MUST NOT contain backslashes.
|
|
153
|
+
*
|
|
154
|
+
* Same shape as lib/sign.js validateSkillPath(); the two functions
|
|
155
|
+
* are intentionally duplicated rather than cross-imported so the
|
|
156
|
+
* verify path has no runtime dependency on the sign path.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} skillPath
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function validateSkillPath(skillPath) {
|
|
162
|
+
if (typeof skillPath !== 'string') {
|
|
163
|
+
throw new Error(`[verify] manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
164
|
+
}
|
|
165
|
+
// Backslash check runs BEFORE the prefix check so a Windows-style
|
|
166
|
+
// path ("skills\foo\skill.md") returns the clearer "use forward
|
|
167
|
+
// slashes" diagnostic, not the misleading "must start with skills/".
|
|
168
|
+
if (skillPath.includes('\\')) {
|
|
169
|
+
throw new Error(`[verify] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
|
|
170
|
+
}
|
|
171
|
+
if (!skillPath.startsWith('skills/')) {
|
|
172
|
+
throw new Error(`[verify] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
|
|
173
|
+
}
|
|
174
|
+
if (skillPath.includes('..')) {
|
|
175
|
+
throw new Error(`[verify] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
|
|
176
|
+
}
|
|
177
|
+
return skillPath;
|
|
178
|
+
}
|
|
179
|
+
|
|
107
180
|
function verifySkill(skill, publicKey) {
|
|
108
181
|
if (!skill.signature) {
|
|
109
182
|
return { status: 'missing_sig', reason: 'No Ed25519 signature in manifest — run: node lib/sign.js sign-all' };
|
|
@@ -128,7 +201,8 @@ function verifySkill(skill, publicKey) {
|
|
|
128
201
|
}
|
|
129
202
|
|
|
130
203
|
function sign(content, privateKey) {
|
|
131
|
-
const
|
|
204
|
+
const normalized = normalize(content);
|
|
205
|
+
const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
|
|
132
206
|
key: privateKey,
|
|
133
207
|
dsaEncoding: 'ieee-p1363'
|
|
134
208
|
});
|
|
@@ -138,7 +212,8 @@ function sign(content, privateKey) {
|
|
|
138
212
|
function verify(content, signatureBase64, publicKey) {
|
|
139
213
|
try {
|
|
140
214
|
const signature = Buffer.from(signatureBase64, 'base64');
|
|
141
|
-
|
|
215
|
+
const normalized = normalize(content);
|
|
216
|
+
return crypto.verify(null, Buffer.from(normalized, 'utf8'), {
|
|
142
217
|
key: publicKey,
|
|
143
218
|
dsaEncoding: 'ieee-p1363'
|
|
144
219
|
}, signature);
|
|
@@ -161,6 +236,136 @@ function loadManifest() {
|
|
|
161
236
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
162
237
|
}
|
|
163
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Load the manifest and validate it against
|
|
241
|
+
* lib/schemas/manifest.schema.json + the path-traversal guard.
|
|
242
|
+
*
|
|
243
|
+
* Throws on schema violation OR traversal-pattern paths. Either case
|
|
244
|
+
* is a fatal-class bug — surface it loudly rather than verify-against-
|
|
245
|
+
* a-corrupt-manifest.
|
|
246
|
+
*
|
|
247
|
+
* @returns {object}
|
|
248
|
+
*/
|
|
249
|
+
function loadManifestValidated() {
|
|
250
|
+
const manifest = loadManifest();
|
|
251
|
+
const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
|
|
252
|
+
const errors = validateAgainstSchema(manifest, schema, 'manifest');
|
|
253
|
+
if (errors.length > 0) {
|
|
254
|
+
const detail = errors.slice(0, 10).map(e => ' - ' + e).join('\n');
|
|
255
|
+
const more = errors.length > 10 ? `\n ...and ${errors.length - 10} more` : '';
|
|
256
|
+
throw new Error(`[verify] manifest.json failed schema validation:\n${detail}${more}`);
|
|
257
|
+
}
|
|
258
|
+
if (!Array.isArray(manifest.skills)) {
|
|
259
|
+
throw new Error('[verify] manifest.json: skills must be an array');
|
|
260
|
+
}
|
|
261
|
+
for (const skill of manifest.skills) {
|
|
262
|
+
validateSkillPath(skill.path);
|
|
263
|
+
}
|
|
264
|
+
return manifest;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- JSON schema validator (subset) ---
|
|
268
|
+
//
|
|
269
|
+
// Mirrors lib/validate-cve-catalog.js's inline validator. Supports the
|
|
270
|
+
// schema features manifest.schema.json actually uses: type, required,
|
|
271
|
+
// properties, additionalProperties, items, pattern, minLength,
|
|
272
|
+
// minItems, $defs / $ref (root-relative only — "#/$defs/foo"). Zero
|
|
273
|
+
// external deps.
|
|
274
|
+
|
|
275
|
+
function typeOf(value) {
|
|
276
|
+
if (value === null) return 'null';
|
|
277
|
+
if (Array.isArray(value)) return 'array';
|
|
278
|
+
return typeof value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function typeMatches(value, expected) {
|
|
282
|
+
if (Array.isArray(expected)) return expected.some(t => typeMatches(value, t));
|
|
283
|
+
const actual = typeOf(value);
|
|
284
|
+
if (expected === 'integer') return actual === 'number' && Number.isInteger(value);
|
|
285
|
+
return actual === expected;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function resolveRef(ref, root) {
|
|
289
|
+
if (!ref.startsWith('#/')) {
|
|
290
|
+
throw new Error(`[verify] unsupported $ref form (must be root-relative): ${ref}`);
|
|
291
|
+
}
|
|
292
|
+
const parts = ref.slice(2).split('/');
|
|
293
|
+
let cur = root;
|
|
294
|
+
for (const p of parts) {
|
|
295
|
+
if (cur === undefined || cur === null) {
|
|
296
|
+
throw new Error(`[verify] cannot resolve $ref ${ref}`);
|
|
297
|
+
}
|
|
298
|
+
cur = cur[p];
|
|
299
|
+
}
|
|
300
|
+
if (cur === undefined) {
|
|
301
|
+
throw new Error(`[verify] $ref ${ref} did not resolve`);
|
|
302
|
+
}
|
|
303
|
+
return cur;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function validateAgainstSchema(value, schema, here, root) {
|
|
307
|
+
const rootSchema = root || schema;
|
|
308
|
+
const errors = [];
|
|
309
|
+
let effectiveSchema = schema;
|
|
310
|
+
if (schema && schema.$ref) {
|
|
311
|
+
effectiveSchema = resolveRef(schema.$ref, rootSchema);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (effectiveSchema.type !== undefined) {
|
|
315
|
+
if (!typeMatches(value, effectiveSchema.type)) {
|
|
316
|
+
errors.push(`${here}: expected type ${JSON.stringify(effectiveSchema.type)}, got ${typeOf(value)}`);
|
|
317
|
+
return errors;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const t = typeOf(value);
|
|
322
|
+
|
|
323
|
+
if (t === 'string') {
|
|
324
|
+
if (effectiveSchema.minLength !== undefined && value.length < effectiveSchema.minLength) {
|
|
325
|
+
errors.push(`${here}: string shorter than minLength ${effectiveSchema.minLength}`);
|
|
326
|
+
}
|
|
327
|
+
if (effectiveSchema.pattern !== undefined) {
|
|
328
|
+
const re = new RegExp(effectiveSchema.pattern);
|
|
329
|
+
if (!re.test(value)) {
|
|
330
|
+
errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${effectiveSchema.pattern}/`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (effectiveSchema.format === 'uri') {
|
|
334
|
+
try { new URL(value); } catch { errors.push(`${here}: not a valid URI`); }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (t === 'array') {
|
|
339
|
+
if (effectiveSchema.minItems !== undefined && value.length < effectiveSchema.minItems) {
|
|
340
|
+
errors.push(`${here}: array shorter than minItems ${effectiveSchema.minItems}`);
|
|
341
|
+
}
|
|
342
|
+
if (effectiveSchema.items !== undefined) {
|
|
343
|
+
value.forEach((item, idx) => {
|
|
344
|
+
errors.push(...validateAgainstSchema(item, effectiveSchema.items, `${here}[${idx}]`, rootSchema));
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (t === 'object') {
|
|
350
|
+
if (effectiveSchema.required) {
|
|
351
|
+
for (const req of effectiveSchema.required) {
|
|
352
|
+
if (!(req in value)) errors.push(`${here}: missing required field "${req}"`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const props = effectiveSchema.properties || {};
|
|
356
|
+
const allowAdditional = effectiveSchema.additionalProperties !== false;
|
|
357
|
+
for (const [k, v] of Object.entries(value)) {
|
|
358
|
+
if (k in props) {
|
|
359
|
+
errors.push(...validateAgainstSchema(v, props[k], `${here}.${k}`, rootSchema));
|
|
360
|
+
} else if (!allowAdditional) {
|
|
361
|
+
errors.push(`${here}: unexpected property "${k}"`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return errors;
|
|
367
|
+
}
|
|
368
|
+
|
|
164
369
|
/**
|
|
165
370
|
* Public key fingerprint(s) of the DER-encoded SPKI public key,
|
|
166
371
|
* base64-encoded. Emits both:
|
|
@@ -238,24 +443,44 @@ if (require.main === module) {
|
|
|
238
443
|
if (result.no_key) process.exit(1);
|
|
239
444
|
|
|
240
445
|
const total = Object.values(result).filter(Array.isArray).flat().length;
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
446
|
+
// S5 ordering: verdict line first, fingerprint banner after.
|
|
447
|
+
// An operator scanning `gh run watch` output should never see a
|
|
448
|
+
// fingerprint banner without first seeing whether the verdict
|
|
449
|
+
// was pass or fail. The previous order printed the success
|
|
450
|
+
// summary then the fingerprint; if verification was actually
|
|
451
|
+
// failing (TAMPERED / UNSIGNED / MISSING) the success line was
|
|
452
|
+
// never reached but the fingerprint had already been printed,
|
|
453
|
+
// which can read as "success" at a glance.
|
|
454
|
+
if (result.invalid.length > 0) {
|
|
455
|
+
console.error(`\n[verify] ${result.invalid.length}/${total} FAILED — TAMPERED: ${result.invalid.join(', ')}`);
|
|
456
|
+
} else if (result.missing_sig.length > 0) {
|
|
457
|
+
console.warn(`\n[verify] ${result.missing_sig.length}/${total} UNSIGNED: ${result.missing_sig.join(', ')}`);
|
|
458
|
+
} else if (result.missing_file.length > 0) {
|
|
459
|
+
console.error(`\n[verify] ${result.missing_file.length}/${total} MISSING: ${result.missing_file.join(', ')}`);
|
|
460
|
+
} else {
|
|
461
|
+
console.log(`\n[verify] All skills verified. ${result.valid.length}/${total} skills passed Ed25519 verification.`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Fingerprint banner comes AFTER the verdict.
|
|
246
465
|
const pubKey = loadPublicKey();
|
|
247
466
|
const fp = publicKeyFingerprint(pubKey);
|
|
248
|
-
console.log(`\n[verify] ${result.valid.length}/${total} skills passed Ed25519 verification.`);
|
|
249
467
|
console.log(`[verify] Public key: keys/public.pem`);
|
|
250
468
|
console.log(`[verify] ${fp.sha256}`);
|
|
251
469
|
console.log(`[verify] ${fp.sha3_512}`);
|
|
252
470
|
|
|
253
|
-
if (result.invalid.length > 0)
|
|
254
|
-
if (result.missing_sig.length > 0)
|
|
255
|
-
if (result.missing_file.length > 0)
|
|
471
|
+
if (result.invalid.length > 0) process.exit(1);
|
|
472
|
+
if (result.missing_sig.length > 0) process.exit(1);
|
|
473
|
+
if (result.missing_file.length > 0) process.exit(1);
|
|
256
474
|
|
|
257
|
-
console.log('[verify] All skills verified.');
|
|
258
475
|
process.exit(0);
|
|
259
476
|
}
|
|
260
477
|
|
|
261
|
-
module.exports = {
|
|
478
|
+
module.exports = {
|
|
479
|
+
verifyAll,
|
|
480
|
+
verifyOne,
|
|
481
|
+
signAll,
|
|
482
|
+
normalize,
|
|
483
|
+
validateSkillPath,
|
|
484
|
+
loadManifestValidated,
|
|
485
|
+
validateAgainstSchema,
|
|
486
|
+
};
|
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-14T14:28:31.798Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|