@blamejs/exceptd-skills 0.12.11 → 0.12.15
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 +243 -0
- package/bin/exceptd.js +299 -48
- package/data/_indexes/_meta.json +49 -48
- package/data/_indexes/activity-feed.json +13 -5
- package/data/_indexes/catalog-summaries.json +51 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +339 -0
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +505 -47
- package/lib/lint-skills.js +217 -15
- package/lib/playbook-runner.js +1224 -183
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +261 -95
- package/lib/refresh-network.js +208 -18
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +83 -7
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +213 -7
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +313 -16
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +196 -20
- package/package.json +3 -1
- package/sbom.cdx.json +9 -9
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +110 -40
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
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,15 @@ 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');
|
|
54
|
+
// Audit G F4 — key-pin file. When present, lib/verify.js compares the live
|
|
55
|
+
// public-key fingerprint against the pinned one and fails the verify run
|
|
56
|
+
// if they differ (unless the operator sets KEYS_ROTATED=1). The file format
|
|
57
|
+
// is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
|
|
58
|
+
// shape. The file is OPTIONAL: when missing, the gate warns-and-continues
|
|
59
|
+
// rather than failing — this preserves bootstrap compatibility on fresh
|
|
60
|
+
// clones / new key ceremonies. Patch-class semantics.
|
|
61
|
+
const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT');
|
|
31
62
|
|
|
32
63
|
// --- public API ---
|
|
33
64
|
|
|
@@ -42,7 +73,7 @@ function verifyAll() {
|
|
|
42
73
|
return { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: true };
|
|
43
74
|
}
|
|
44
75
|
|
|
45
|
-
const manifest =
|
|
76
|
+
const manifest = loadManifestValidated();
|
|
46
77
|
const result = { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: false };
|
|
47
78
|
|
|
48
79
|
for (const skill of manifest.skills) {
|
|
@@ -65,7 +96,7 @@ function verifyOne(skillName) {
|
|
|
65
96
|
const publicKey = loadPublicKey();
|
|
66
97
|
if (!publicKey) throw new Error('No public key at keys/public.pem');
|
|
67
98
|
|
|
68
|
-
const manifest =
|
|
99
|
+
const manifest = loadManifestValidated();
|
|
69
100
|
const skill = manifest.skills.find(s => s.name === skillName);
|
|
70
101
|
if (!skill) throw new Error(`Skill not in manifest: ${skillName}`);
|
|
71
102
|
|
|
@@ -81,7 +112,7 @@ function signAll() {
|
|
|
81
112
|
const privateKey = loadPrivateKey();
|
|
82
113
|
if (!privateKey) throw new Error('No private key at .keys/private.pem — run: node lib/sign.js generate-keypair');
|
|
83
114
|
|
|
84
|
-
const manifest =
|
|
115
|
+
const manifest = loadManifestValidated();
|
|
85
116
|
const result = { signed: [], errors: [] };
|
|
86
117
|
|
|
87
118
|
for (const skill of manifest.skills) {
|
|
@@ -104,6 +135,56 @@ function signAll() {
|
|
|
104
135
|
|
|
105
136
|
// --- private helpers ---
|
|
106
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Normalize skill content for byte-stable verification.
|
|
140
|
+
*
|
|
141
|
+
* Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
|
|
142
|
+
* line endings to LF. lib/sign.js applies the exact same transform —
|
|
143
|
+
* see the byte-stability contract in the file header.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} content
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function normalize(content) {
|
|
149
|
+
let s = content;
|
|
150
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
151
|
+
return s.replace(/\r\n/g, '\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate a manifest skill.path entry to prevent path traversal.
|
|
156
|
+
*
|
|
157
|
+
* skill.path MUST be a string.
|
|
158
|
+
* skill.path MUST start with "skills/".
|
|
159
|
+
* skill.path MUST NOT contain "..".
|
|
160
|
+
* skill.path MUST NOT contain backslashes.
|
|
161
|
+
*
|
|
162
|
+
* Same shape as lib/sign.js validateSkillPath(); the two functions
|
|
163
|
+
* are intentionally duplicated rather than cross-imported so the
|
|
164
|
+
* verify path has no runtime dependency on the sign path.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} skillPath
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
function validateSkillPath(skillPath) {
|
|
170
|
+
if (typeof skillPath !== 'string') {
|
|
171
|
+
throw new Error(`[verify] manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
172
|
+
}
|
|
173
|
+
// Backslash check runs BEFORE the prefix check so a Windows-style
|
|
174
|
+
// path ("skills\foo\skill.md") returns the clearer "use forward
|
|
175
|
+
// slashes" diagnostic, not the misleading "must start with skills/".
|
|
176
|
+
if (skillPath.includes('\\')) {
|
|
177
|
+
throw new Error(`[verify] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
|
|
178
|
+
}
|
|
179
|
+
if (!skillPath.startsWith('skills/')) {
|
|
180
|
+
throw new Error(`[verify] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
|
|
181
|
+
}
|
|
182
|
+
if (skillPath.includes('..')) {
|
|
183
|
+
throw new Error(`[verify] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
|
|
184
|
+
}
|
|
185
|
+
return skillPath;
|
|
186
|
+
}
|
|
187
|
+
|
|
107
188
|
function verifySkill(skill, publicKey) {
|
|
108
189
|
if (!skill.signature) {
|
|
109
190
|
return { status: 'missing_sig', reason: 'No Ed25519 signature in manifest — run: node lib/sign.js sign-all' };
|
|
@@ -128,7 +209,8 @@ function verifySkill(skill, publicKey) {
|
|
|
128
209
|
}
|
|
129
210
|
|
|
130
211
|
function sign(content, privateKey) {
|
|
131
|
-
const
|
|
212
|
+
const normalized = normalize(content);
|
|
213
|
+
const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
|
|
132
214
|
key: privateKey,
|
|
133
215
|
dsaEncoding: 'ieee-p1363'
|
|
134
216
|
});
|
|
@@ -138,7 +220,8 @@ function sign(content, privateKey) {
|
|
|
138
220
|
function verify(content, signatureBase64, publicKey) {
|
|
139
221
|
try {
|
|
140
222
|
const signature = Buffer.from(signatureBase64, 'base64');
|
|
141
|
-
|
|
223
|
+
const normalized = normalize(content);
|
|
224
|
+
return crypto.verify(null, Buffer.from(normalized, 'utf8'), {
|
|
142
225
|
key: publicKey,
|
|
143
226
|
dsaEncoding: 'ieee-p1363'
|
|
144
227
|
}, signature);
|
|
@@ -161,6 +244,136 @@ function loadManifest() {
|
|
|
161
244
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
162
245
|
}
|
|
163
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Load the manifest and validate it against
|
|
249
|
+
* lib/schemas/manifest.schema.json + the path-traversal guard.
|
|
250
|
+
*
|
|
251
|
+
* Throws on schema violation OR traversal-pattern paths. Either case
|
|
252
|
+
* is a fatal-class bug — surface it loudly rather than verify-against-
|
|
253
|
+
* a-corrupt-manifest.
|
|
254
|
+
*
|
|
255
|
+
* @returns {object}
|
|
256
|
+
*/
|
|
257
|
+
function loadManifestValidated() {
|
|
258
|
+
const manifest = loadManifest();
|
|
259
|
+
const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
|
|
260
|
+
const errors = validateAgainstSchema(manifest, schema, 'manifest');
|
|
261
|
+
if (errors.length > 0) {
|
|
262
|
+
const detail = errors.slice(0, 10).map(e => ' - ' + e).join('\n');
|
|
263
|
+
const more = errors.length > 10 ? `\n ...and ${errors.length - 10} more` : '';
|
|
264
|
+
throw new Error(`[verify] manifest.json failed schema validation:\n${detail}${more}`);
|
|
265
|
+
}
|
|
266
|
+
if (!Array.isArray(manifest.skills)) {
|
|
267
|
+
throw new Error('[verify] manifest.json: skills must be an array');
|
|
268
|
+
}
|
|
269
|
+
for (const skill of manifest.skills) {
|
|
270
|
+
validateSkillPath(skill.path);
|
|
271
|
+
}
|
|
272
|
+
return manifest;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --- JSON schema validator (subset) ---
|
|
276
|
+
//
|
|
277
|
+
// Mirrors lib/validate-cve-catalog.js's inline validator. Supports the
|
|
278
|
+
// schema features manifest.schema.json actually uses: type, required,
|
|
279
|
+
// properties, additionalProperties, items, pattern, minLength,
|
|
280
|
+
// minItems, $defs / $ref (root-relative only — "#/$defs/foo"). Zero
|
|
281
|
+
// external deps.
|
|
282
|
+
|
|
283
|
+
function typeOf(value) {
|
|
284
|
+
if (value === null) return 'null';
|
|
285
|
+
if (Array.isArray(value)) return 'array';
|
|
286
|
+
return typeof value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function typeMatches(value, expected) {
|
|
290
|
+
if (Array.isArray(expected)) return expected.some(t => typeMatches(value, t));
|
|
291
|
+
const actual = typeOf(value);
|
|
292
|
+
if (expected === 'integer') return actual === 'number' && Number.isInteger(value);
|
|
293
|
+
return actual === expected;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveRef(ref, root) {
|
|
297
|
+
if (!ref.startsWith('#/')) {
|
|
298
|
+
throw new Error(`[verify] unsupported $ref form (must be root-relative): ${ref}`);
|
|
299
|
+
}
|
|
300
|
+
const parts = ref.slice(2).split('/');
|
|
301
|
+
let cur = root;
|
|
302
|
+
for (const p of parts) {
|
|
303
|
+
if (cur === undefined || cur === null) {
|
|
304
|
+
throw new Error(`[verify] cannot resolve $ref ${ref}`);
|
|
305
|
+
}
|
|
306
|
+
cur = cur[p];
|
|
307
|
+
}
|
|
308
|
+
if (cur === undefined) {
|
|
309
|
+
throw new Error(`[verify] $ref ${ref} did not resolve`);
|
|
310
|
+
}
|
|
311
|
+
return cur;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function validateAgainstSchema(value, schema, here, root) {
|
|
315
|
+
const rootSchema = root || schema;
|
|
316
|
+
const errors = [];
|
|
317
|
+
let effectiveSchema = schema;
|
|
318
|
+
if (schema && schema.$ref) {
|
|
319
|
+
effectiveSchema = resolveRef(schema.$ref, rootSchema);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (effectiveSchema.type !== undefined) {
|
|
323
|
+
if (!typeMatches(value, effectiveSchema.type)) {
|
|
324
|
+
errors.push(`${here}: expected type ${JSON.stringify(effectiveSchema.type)}, got ${typeOf(value)}`);
|
|
325
|
+
return errors;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const t = typeOf(value);
|
|
330
|
+
|
|
331
|
+
if (t === 'string') {
|
|
332
|
+
if (effectiveSchema.minLength !== undefined && value.length < effectiveSchema.minLength) {
|
|
333
|
+
errors.push(`${here}: string shorter than minLength ${effectiveSchema.minLength}`);
|
|
334
|
+
}
|
|
335
|
+
if (effectiveSchema.pattern !== undefined) {
|
|
336
|
+
const re = new RegExp(effectiveSchema.pattern);
|
|
337
|
+
if (!re.test(value)) {
|
|
338
|
+
errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${effectiveSchema.pattern}/`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (effectiveSchema.format === 'uri') {
|
|
342
|
+
try { new URL(value); } catch { errors.push(`${here}: not a valid URI`); }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (t === 'array') {
|
|
347
|
+
if (effectiveSchema.minItems !== undefined && value.length < effectiveSchema.minItems) {
|
|
348
|
+
errors.push(`${here}: array shorter than minItems ${effectiveSchema.minItems}`);
|
|
349
|
+
}
|
|
350
|
+
if (effectiveSchema.items !== undefined) {
|
|
351
|
+
value.forEach((item, idx) => {
|
|
352
|
+
errors.push(...validateAgainstSchema(item, effectiveSchema.items, `${here}[${idx}]`, rootSchema));
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (t === 'object') {
|
|
358
|
+
if (effectiveSchema.required) {
|
|
359
|
+
for (const req of effectiveSchema.required) {
|
|
360
|
+
if (!(req in value)) errors.push(`${here}: missing required field "${req}"`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const props = effectiveSchema.properties || {};
|
|
364
|
+
const allowAdditional = effectiveSchema.additionalProperties !== false;
|
|
365
|
+
for (const [k, v] of Object.entries(value)) {
|
|
366
|
+
if (k in props) {
|
|
367
|
+
errors.push(...validateAgainstSchema(v, props[k], `${here}.${k}`, rootSchema));
|
|
368
|
+
} else if (!allowAdditional) {
|
|
369
|
+
errors.push(`${here}: unexpected property "${k}"`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return errors;
|
|
375
|
+
}
|
|
376
|
+
|
|
164
377
|
/**
|
|
165
378
|
* Public key fingerprint(s) of the DER-encoded SPKI public key,
|
|
166
379
|
* base64-encoded. Emits both:
|
|
@@ -181,6 +394,38 @@ function loadManifest() {
|
|
|
181
394
|
* @param {string|null} pemKey PEM-encoded public key (or null)
|
|
182
395
|
* @returns {{sha256: string, sha3_512: string}|{error: string}}
|
|
183
396
|
*/
|
|
397
|
+
/**
|
|
398
|
+
* Audit G F4 — compare the live public-key fingerprint against the optional
|
|
399
|
+
* pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
|
|
400
|
+
* { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
|
|
401
|
+
* Callers should warn and continue.
|
|
402
|
+
* { status: 'match' } — live fingerprint matches the pin.
|
|
403
|
+
* { status: 'mismatch', — divergence; caller should fail unless
|
|
404
|
+
* expected, actual, KEYS_ROTATED=1 is set in the environment.
|
|
405
|
+
* rotationOverride }
|
|
406
|
+
*
|
|
407
|
+
* @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
|
|
408
|
+
* @param {string} [pinPath] optional override (testability)
|
|
409
|
+
*/
|
|
410
|
+
function checkExpectedFingerprint(liveFp, pinPath) {
|
|
411
|
+
const p = pinPath || EXPECTED_FINGERPRINT_PATH;
|
|
412
|
+
if (!fs.existsSync(p)) return { status: 'no-pin' };
|
|
413
|
+
if (!liveFp || typeof liveFp.sha256 !== 'string') {
|
|
414
|
+
return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
|
|
415
|
+
}
|
|
416
|
+
const expected = fs.readFileSync(p, 'utf8').trim();
|
|
417
|
+
// Tolerate trailing comment / whitespace on the same line; the file's
|
|
418
|
+
// first non-empty line is the canonical fingerprint.
|
|
419
|
+
const firstLine = expected.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || '';
|
|
420
|
+
if (firstLine === liveFp.sha256) return { status: 'match' };
|
|
421
|
+
return {
|
|
422
|
+
status: 'mismatch',
|
|
423
|
+
expected: firstLine,
|
|
424
|
+
actual: liveFp.sha256,
|
|
425
|
+
rotationOverride: process.env.KEYS_ROTATED === '1',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
184
429
|
function publicKeyFingerprint(pemKey) {
|
|
185
430
|
if (!pemKey) return { sha256: '(no key)', sha3_512: '(no key)' };
|
|
186
431
|
try {
|
|
@@ -238,24 +483,76 @@ if (require.main === module) {
|
|
|
238
483
|
if (result.no_key) process.exit(1);
|
|
239
484
|
|
|
240
485
|
const total = Object.values(result).filter(Array.isArray).flat().length;
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
486
|
+
// S5 ordering: verdict line first, fingerprint banner after.
|
|
487
|
+
// An operator scanning `gh run watch` output should never see a
|
|
488
|
+
// fingerprint banner without first seeing whether the verdict
|
|
489
|
+
// was pass or fail. The previous order printed the success
|
|
490
|
+
// summary then the fingerprint; if verification was actually
|
|
491
|
+
// failing (TAMPERED / UNSIGNED / MISSING) the success line was
|
|
492
|
+
// never reached but the fingerprint had already been printed,
|
|
493
|
+
// which can read as "success" at a glance.
|
|
494
|
+
if (result.invalid.length > 0) {
|
|
495
|
+
console.error(`\n[verify] ${result.invalid.length}/${total} FAILED — TAMPERED: ${result.invalid.join(', ')}`);
|
|
496
|
+
} else if (result.missing_sig.length > 0) {
|
|
497
|
+
console.warn(`\n[verify] ${result.missing_sig.length}/${total} UNSIGNED: ${result.missing_sig.join(', ')}`);
|
|
498
|
+
} else if (result.missing_file.length > 0) {
|
|
499
|
+
console.error(`\n[verify] ${result.missing_file.length}/${total} MISSING: ${result.missing_file.join(', ')}`);
|
|
500
|
+
} else {
|
|
501
|
+
console.log(`\n[verify] All skills verified. ${result.valid.length}/${total} skills passed Ed25519 verification.`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Fingerprint banner comes AFTER the verdict.
|
|
246
505
|
const pubKey = loadPublicKey();
|
|
247
506
|
const fp = publicKeyFingerprint(pubKey);
|
|
248
|
-
console.log(`\n[verify] ${result.valid.length}/${total} skills passed Ed25519 verification.`);
|
|
249
507
|
console.log(`[verify] Public key: keys/public.pem`);
|
|
250
508
|
console.log(`[verify] ${fp.sha256}`);
|
|
251
509
|
console.log(`[verify] ${fp.sha3_512}`);
|
|
252
510
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
511
|
+
// Audit G F4 — pin check. When keys/EXPECTED_FINGERPRINT exists, the
|
|
512
|
+
// live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
|
|
513
|
+
// intentionally override). When the file is absent, emit a single-line
|
|
514
|
+
// warning but continue — fresh clones / bootstrap workflows should not
|
|
515
|
+
// fail the gate before the operator has committed a fingerprint.
|
|
516
|
+
const pinResult = checkExpectedFingerprint(fp);
|
|
517
|
+
if (pinResult.status === 'no-pin') {
|
|
518
|
+
console.warn(
|
|
519
|
+
`[verify] WARN: keys/EXPECTED_FINGERPRINT not present — key-pin check skipped. ` +
|
|
520
|
+
`Create it with the current ${fp.sha256} line to enable pinning.`
|
|
521
|
+
);
|
|
522
|
+
} else if (pinResult.status === 'mismatch') {
|
|
523
|
+
if (pinResult.rotationOverride) {
|
|
524
|
+
console.warn(
|
|
525
|
+
`[verify] WARN: live key fingerprint ${pinResult.actual} differs from pin ` +
|
|
526
|
+
`${pinResult.expected}. KEYS_ROTATED=1 set — accepting rotation. ` +
|
|
527
|
+
`Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
|
|
528
|
+
);
|
|
529
|
+
} else {
|
|
530
|
+
console.error(
|
|
531
|
+
`[verify] FAIL: live key fingerprint ${pinResult.actual} does not match ` +
|
|
532
|
+
`keys/EXPECTED_FINGERPRINT ${pinResult.expected}. ` +
|
|
533
|
+
`If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
|
|
534
|
+
`then commit the new fingerprint to keys/EXPECTED_FINGERPRINT.`
|
|
535
|
+
);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (result.invalid.length > 0) process.exit(1);
|
|
541
|
+
if (result.missing_sig.length > 0) process.exit(1);
|
|
542
|
+
if (result.missing_file.length > 0) process.exit(1);
|
|
256
543
|
|
|
257
|
-
console.log('[verify] All skills verified.');
|
|
258
544
|
process.exit(0);
|
|
259
545
|
}
|
|
260
546
|
|
|
261
|
-
module.exports = {
|
|
547
|
+
module.exports = {
|
|
548
|
+
verifyAll,
|
|
549
|
+
verifyOne,
|
|
550
|
+
signAll,
|
|
551
|
+
normalize,
|
|
552
|
+
validateSkillPath,
|
|
553
|
+
loadManifestValidated,
|
|
554
|
+
validateAgainstSchema,
|
|
555
|
+
publicKeyFingerprint,
|
|
556
|
+
checkExpectedFingerprint,
|
|
557
|
+
EXPECTED_FINGERPRINT_PATH,
|
|
558
|
+
};
|
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-14T15:55:39.383Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ca9d31e533c9d494e1ac5875e0a45176101438c3d75d44387187e367ccae21ad manifest-snapshot.json
|