@blamejs/exceptd-skills 0.19.32 → 0.19.34
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 +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/sign.js
CHANGED
|
@@ -2,78 +2,11 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Ed25519 keypair management and skill signing.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Byte-stability contract (must mirror lib/verify.js):
|
|
12
|
-
* Skill content is normalized BEFORE the bytes are signed:
|
|
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/verify.js. A skill file checked
|
|
16
|
-
* out with core.autocrlf=true on Windows therefore signs to the SAME
|
|
17
|
-
* signature as the LF copy on Linux CI — closing the regression class
|
|
18
|
-
* that broke v0.11.x signatures across the Windows/CI line-ending
|
|
19
|
-
* boundary. ANY change to normalize() requires the matching change in
|
|
20
|
-
* lib/verify.js; round-trip stability is a hard contract.
|
|
21
|
-
*
|
|
22
|
-
* Manifest entries are also validated before iteration: skill.path must
|
|
23
|
-
* begin with "skills/" and must not contain ".." or backslashes (see
|
|
24
|
-
* validateSkillPath() below). Without this a tampered manifest could
|
|
25
|
-
* sign or verify arbitrary files outside the skills/ tree.
|
|
26
|
-
*
|
|
27
|
-
* Manifest signing contract (must mirror lib/verify.js):
|
|
28
|
-
* After all individual skill signatures are written, sign-all signs
|
|
29
|
-
* the manifest itself. The canonical bytes are computed as:
|
|
30
|
-
* 1. Read the manifest object after all skill signatures land.
|
|
31
|
-
* 2. Delete the top-level `manifest_signature` field if present
|
|
32
|
-
* (idempotency — re-signing after rotation must produce the same
|
|
33
|
-
* canonical bytes whether or not a stale signature is there).
|
|
34
|
-
* 3. Serialize via JSON.stringify(obj, sortedTopLevelKeys, 2). The
|
|
35
|
-
* top-level keys are stringified in lexicographic order so a
|
|
36
|
-
* re-ordered manifest signs to the same bytes. Nested objects
|
|
37
|
-
* keep their natural key order (skills[] entries already follow
|
|
38
|
-
* a stable convention).
|
|
39
|
-
* 4. Apply normalize() (CRLF→LF, BOM strip) — same transform skills
|
|
40
|
-
* use, so the manifest signature survives any line-ending churn.
|
|
41
|
-
* ANY change to canonicalManifestBytes() or this contract requires
|
|
42
|
-
* the matching change in lib/verify.js. A coordinated attacker who
|
|
43
|
-
* rewrites manifest.json + manifest-snapshot.json + manifest-snapshot.sha256
|
|
44
|
-
* without the private key produces a manifest_signature mismatch that
|
|
45
|
-
* lib/verify.js refuses to load.
|
|
46
|
-
*
|
|
47
|
-
* Windows ACL contract:
|
|
48
|
-
* On win32, `fs.writeFileSync(..., { mode: 0o600 })` only affects
|
|
49
|
-
* read-only attributes — it does NOT establish a POSIX-style restrictive
|
|
50
|
-
* ACL. Any process running under the same desktop user can read the key
|
|
51
|
-
* by default ACL inheritance from the parent. After writing
|
|
52
|
-
* .keys/private.pem on Windows, restrictWindowsAcl() shells to icacls
|
|
53
|
-
* to strip inherited entries and grant Full Control only to the current
|
|
54
|
-
* user. If icacls is unavailable (Server Core, exotic shells), the call
|
|
55
|
-
* warns to stderr and generateKeypair() returns { aclHardened: false }.
|
|
56
|
-
* The CLI dispatch then exits non-zero so automation (bootstrap.js,
|
|
57
|
-
* doctor --fix) does not treat an unhardened key as a clean generation —
|
|
58
|
-
* set EXCEPTD_ALLOW_WEAK_KEY_ACL=1 to accept the weaker ACL on a
|
|
59
|
-
* single-user host. (The key write itself still succeeds; the non-zero
|
|
60
|
-
* exit signals "key present but not ACL-hardened," not "no key.")
|
|
61
|
-
*
|
|
62
|
-
* Signing ceremony:
|
|
63
|
-
* 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
|
|
64
|
-
* 2. node lib/sign.js sign-all — sign all skills (after any content change)
|
|
65
|
-
* 3. node lib/verify.js — verify all signatures
|
|
66
|
-
*
|
|
67
|
-
* Key rotation:
|
|
68
|
-
* 1. node lib/sign.js generate-keypair --rotate — generate new keypair, old sigs become invalid
|
|
69
|
-
* 2. node lib/sign.js sign-all — re-sign all skills with new key
|
|
70
|
-
* 3. Commit keys/public.pem update
|
|
71
|
-
*
|
|
72
|
-
* Usage:
|
|
73
|
-
* node lib/sign.js generate-keypair [--rotate] — generate Ed25519 keypair
|
|
74
|
-
* node lib/sign.js sign-all — sign all skills in manifest
|
|
75
|
-
* node lib/sign.js sign <skill-name> — sign one skill
|
|
76
|
-
* node lib/sign.js show-pubkey — print the public key
|
|
7
|
+
* normalize() and canonicalManifestBytes() are one half of a contract with
|
|
8
|
+
* lib/verify.js — a change to either requires the matching change there, or the
|
|
9
|
+
* verifier rejects everything this signs.
|
|
77
10
|
*/
|
|
78
11
|
|
|
79
12
|
const fs = require('fs');
|
|
@@ -81,11 +14,8 @@ const path = require('path');
|
|
|
81
14
|
const crypto = require('crypto');
|
|
82
15
|
const { execFileSync } = require('child_process');
|
|
83
16
|
const { safeExit } = require('./exit-codes');
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// Requiring verify.js is side-effect-free (its CLI block is guarded by
|
|
87
|
-
// `require.main === module`) and verify.js does not require sign.js, so there
|
|
88
|
-
// is no circular dependency.
|
|
17
|
+
// The verifier's own validator, so signer and verifier cannot disagree about
|
|
18
|
+
// what a well-formed manifest is. verify.js does not require sign.js.
|
|
89
19
|
const { validateAgainstSchema } = require('./verify');
|
|
90
20
|
|
|
91
21
|
const ROOT = path.join(__dirname, '..');
|
|
@@ -96,14 +26,9 @@ const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private.pem');
|
|
|
96
26
|
const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
|
|
97
27
|
const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
|
|
98
28
|
|
|
99
|
-
// --- public API ---
|
|
100
|
-
|
|
101
29
|
/**
|
|
102
|
-
* Generate an Ed25519 keypair.
|
|
103
|
-
*
|
|
104
|
-
* Public key → keys/public.pem (tracked)
|
|
105
|
-
*
|
|
106
|
-
* @param {{ rotate: boolean }} options
|
|
30
|
+
* Generate an Ed25519 keypair: private key → .keys/private.pem, public key →
|
|
31
|
+
* keys/public.pem. `aclHardened` comes back false on win32 when icacls failed.
|
|
107
32
|
*/
|
|
108
33
|
function generateKeypair({ rotate = false } = {}) {
|
|
109
34
|
fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -114,14 +39,9 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
114
39
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
|
115
40
|
});
|
|
116
41
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// the key path. EEXIST translates to the same operator-facing refusals the
|
|
121
|
-
// bootstrap has always surfaced; --rotate is a deliberate re-key (flag 'w').
|
|
122
|
-
// The public-key refusal is the v0.11.x signature-regression guard: a host
|
|
123
|
-
// with a working pubkey but no privkey must NOT silently regenerate the
|
|
124
|
-
// pubkey (that orphans every shipped signature) — force --rotate to confirm.
|
|
42
|
+
// 'wx' (O_CREAT|O_EXCL) refuses an existing key in the open itself: no
|
|
43
|
+
// existsSync to race against, and no following a preplanted symlink. Only
|
|
44
|
+
// --rotate re-keys ('w'); silently regenerating orphans every signature.
|
|
125
45
|
const openFlag = rotate ? 'w' : 'wx';
|
|
126
46
|
let privFd;
|
|
127
47
|
try {
|
|
@@ -139,8 +59,7 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
139
59
|
pubFd = fs.openSync(PUBLIC_KEY_PATH, openFlag, 0o644);
|
|
140
60
|
} catch (e) {
|
|
141
61
|
fs.closeSync(privFd);
|
|
142
|
-
// A
|
|
143
|
-
// half-written signing identity behind.
|
|
62
|
+
// A public-key clash must not leave a half signing identity behind.
|
|
144
63
|
try { fs.unlinkSync(PRIVATE_KEY_PATH); } catch { /* best effort */ }
|
|
145
64
|
if (e.code === 'EEXIST') {
|
|
146
65
|
console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
|
|
@@ -158,9 +77,6 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
158
77
|
fs.closeSync(pubFd);
|
|
159
78
|
}
|
|
160
79
|
|
|
161
|
-
// on win32, fs.writeFileSync `mode` does not produce
|
|
162
|
-
// a POSIX-style restrictive ACL. Tighten via icacls so other desktop
|
|
163
|
-
// users on the same workstation / CI runner can't read the key.
|
|
164
80
|
const aclHardened = restrictWindowsAcl(PRIVATE_KEY_PATH);
|
|
165
81
|
|
|
166
82
|
if (rotate) {
|
|
@@ -176,10 +92,6 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
176
92
|
|
|
177
93
|
console.log('\nNext steps:');
|
|
178
94
|
if (rotate) {
|
|
179
|
-
// After --rotate the private key IS present, so `doctor --fix`'s
|
|
180
|
-
// missing-key path won't fire. Tell the operator to re-sign
|
|
181
|
-
// directly. (doctor --fix v0.12.41+ also detects this case and
|
|
182
|
-
// chains sign-all, so either path converges.)
|
|
183
95
|
console.log(' 1. exceptd doctor --fix — detects post-rotate stale signatures and chains sign-all');
|
|
184
96
|
console.log(' (or: node $(exceptd path)/lib/sign.js sign-all — re-sign directly)');
|
|
185
97
|
console.log(' 2. exceptd doctor — confirm signatures verify against the new public key');
|
|
@@ -192,23 +104,13 @@ function generateKeypair({ rotate = false } = {}) {
|
|
|
192
104
|
return { aclHardened };
|
|
193
105
|
}
|
|
194
106
|
|
|
195
|
-
/**
|
|
196
|
-
* Sign all skills in manifest.json using the private key.
|
|
197
|
-
* Updates manifest.json with Ed25519 signatures.
|
|
198
|
-
*
|
|
199
|
-
* Each manifest entry's `path` is validated through validateSkillPath()
|
|
200
|
-
* BEFORE the file is read — a tampered manifest with an out-of-tree
|
|
201
|
-
* path will reject the whole run.
|
|
202
|
-
*/
|
|
107
|
+
/** Sign every skill in manifest.json and then the manifest itself, in place. */
|
|
203
108
|
function signAll() {
|
|
204
109
|
const privateKey = loadPrivateKey();
|
|
205
110
|
const manifest = loadManifest();
|
|
206
|
-
// Schema
|
|
207
|
-
//
|
|
111
|
+
// Schema and every skill.path are checked before any I/O, so a tampered
|
|
112
|
+
// manifest is refused whole rather than left half-signed.
|
|
208
113
|
validateManifestSchema(manifest, 'sign-all');
|
|
209
|
-
// Validate every entry's path before doing any I/O. Reject the whole
|
|
210
|
-
// manifest on the first traversal attempt — we never want to sign
|
|
211
|
-
// half a manifest then exit non-zero with a partial mutation.
|
|
212
114
|
for (const skill of manifest.skills) {
|
|
213
115
|
validateSkillPath(skill.path);
|
|
214
116
|
}
|
|
@@ -230,21 +132,14 @@ function signAll() {
|
|
|
230
132
|
signed++;
|
|
231
133
|
}
|
|
232
134
|
|
|
233
|
-
//
|
|
234
|
-
// manifest_signature field so the canonical bytes are deterministic
|
|
235
|
-
// across re-runs, signs with the private key, then writes the result.
|
|
236
|
-
// A coordinated attacker who rewrites the manifest (and snapshot, and
|
|
237
|
-
// snapshot SHA) without the private key produces an invalid manifest
|
|
238
|
-
// signature; lib/verify.js refuses to load the manifest.
|
|
135
|
+
// Drop any existing signature first so the canonical bytes are re-run stable.
|
|
239
136
|
delete manifest.manifest_signature;
|
|
240
137
|
const manifestSig = signCanonicalManifest(manifest, privateKey);
|
|
241
138
|
manifest.manifest_signature = manifestSig;
|
|
242
139
|
|
|
243
140
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
244
141
|
|
|
245
|
-
// Verdict
|
|
246
|
-
// output should not be able to see "fingerprint: SHA256..." and assume
|
|
247
|
-
// success when errors > 0.
|
|
142
|
+
// Verdict before the fingerprint banner, so "SHA256..." cannot read as success.
|
|
248
143
|
if (errors > 0) {
|
|
249
144
|
console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
|
|
250
145
|
} else {
|
|
@@ -255,15 +150,9 @@ function signAll() {
|
|
|
255
150
|
if (errors > 0) { safeExit(1); return; }
|
|
256
151
|
}
|
|
257
152
|
|
|
258
|
-
/**
|
|
259
|
-
* Sign a single skill by name.
|
|
260
|
-
* @param {string} skillName
|
|
261
|
-
*/
|
|
262
153
|
function signOne(skillName) {
|
|
263
154
|
const privateKey = loadPrivateKey();
|
|
264
155
|
const manifest = loadManifest();
|
|
265
|
-
// Schema-validate BEFORE any mutation/write — refuse to sign a manifest
|
|
266
|
-
// the verifier would reject. Matches lib/verify.js signAll().
|
|
267
156
|
validateManifestSchema(manifest, 'sign');
|
|
268
157
|
const skill = manifest.skills.find(s => s.name === skillName);
|
|
269
158
|
if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
|
|
@@ -275,8 +164,7 @@ function signOne(skillName) {
|
|
|
275
164
|
skill.signed_at = new Date().toISOString();
|
|
276
165
|
delete skill.sha256;
|
|
277
166
|
|
|
278
|
-
//
|
|
279
|
-
// Without this a single-skill sign leaves manifest_signature stale.
|
|
167
|
+
// Changing one skill signature stales the manifest signature, so recompute it.
|
|
280
168
|
delete manifest.manifest_signature;
|
|
281
169
|
manifest.manifest_signature = signCanonicalManifest(manifest, privateKey);
|
|
282
170
|
|
|
@@ -285,21 +173,10 @@ function signOne(skillName) {
|
|
|
285
173
|
printFingerprintBanner();
|
|
286
174
|
}
|
|
287
175
|
|
|
288
|
-
// --- helpers ---
|
|
289
|
-
|
|
290
176
|
/**
|
|
291
|
-
* Normalize
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* line endings to LF. lib/verify.js applies the exact same transform.
|
|
295
|
-
*
|
|
296
|
-
* Without this, a Windows checkout with core.autocrlf=true reads a
|
|
297
|
-
* skill with \r\n while CI reads the same skill with \n — same bytes
|
|
298
|
-
* on disk in git, different bytes in the working tree, different
|
|
299
|
-
* signature. v0.11.x shipped 0/38 verifies for exactly this reason.
|
|
300
|
-
*
|
|
301
|
-
* @param {string} content
|
|
302
|
-
* @returns {string}
|
|
177
|
+
* Normalize content for byte-stable signing: strip a leading UTF-8 BOM, then
|
|
178
|
+
* CRLF → LF, so a core.autocrlf=true checkout signs the bytes CI reads.
|
|
179
|
+
* lib/verify.js applies the identical transform.
|
|
303
180
|
*/
|
|
304
181
|
function normalize(content) {
|
|
305
182
|
let s = content;
|
|
@@ -308,28 +185,14 @@ function normalize(content) {
|
|
|
308
185
|
}
|
|
309
186
|
|
|
310
187
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* skill.path MUST be a string.
|
|
314
|
-
* skill.path MUST start with "skills/".
|
|
315
|
-
* skill.path MUST NOT contain "..".
|
|
316
|
-
* skill.path MUST NOT contain backslashes (POSIX-style forward slashes
|
|
317
|
-
* only — manifest paths are not platform-specific).
|
|
318
|
-
*
|
|
319
|
-
* A tampered manifest with "../../../etc/passwd" or
|
|
320
|
-
* "skills/foo/../../.keys/private.pem" is refused; the whole run
|
|
321
|
-
* aborts before any file I/O.
|
|
322
|
-
*
|
|
323
|
-
* @param {string} skillPath
|
|
324
|
-
* @returns {string}
|
|
188
|
+
* Throw unless skillPath is a POSIX-style string under "skills/" with no "..",
|
|
189
|
+
* so a tampered manifest cannot point the signer outside the skills tree.
|
|
325
190
|
*/
|
|
326
191
|
function validateSkillPath(skillPath) {
|
|
327
192
|
if (typeof skillPath !== 'string') {
|
|
328
193
|
throw new Error(`[sign] manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
329
194
|
}
|
|
330
|
-
//
|
|
331
|
-
// path ("skills\foo\skill.md") returns the clearer "use forward
|
|
332
|
-
// slashes" diagnostic, not the misleading "must start with skills/".
|
|
195
|
+
// Before the prefix check, so a backslash path reports the backslash.
|
|
333
196
|
if (skillPath.includes('\\')) {
|
|
334
197
|
throw new Error(`[sign] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
|
|
335
198
|
}
|
|
@@ -365,21 +228,9 @@ function loadManifest() {
|
|
|
365
228
|
}
|
|
366
229
|
|
|
367
230
|
/**
|
|
368
|
-
* Validate the manifest against lib/schemas/manifest.schema.json
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
* check: a manifest that is path-safe but schema-invalid (unknown
|
|
372
|
-
* per-skill field, malformed version, bad atlas_ref, missing required
|
|
373
|
-
* field) gets a valid manifest_signature here, then lib/verify.js
|
|
374
|
-
* loadManifestValidated() THROWS on the same schema at install time and
|
|
375
|
-
* refuses to verify any skill. The producer must never emit an artifact
|
|
376
|
-
* the consumer rejects, so the signer is held to at least the verifier's
|
|
377
|
-
* bar. The validator itself is imported from lib/verify.js so the two
|
|
378
|
-
* halves can never drift to different rules.
|
|
379
|
-
*
|
|
380
|
-
* @param {object} manifest parsed manifest.json
|
|
381
|
-
* @param {string} who 'sign-all' | 'sign' — surfaced in the error
|
|
382
|
-
* @throws on any schema violation
|
|
231
|
+
* Validate the manifest against lib/schemas/manifest.schema.json before signing:
|
|
232
|
+
* anything schema-invalid signed here throws in lib/verify.js
|
|
233
|
+
* loadManifestValidated() at install time. `who` is the calling verb.
|
|
383
234
|
*/
|
|
384
235
|
function validateManifestSchema(manifest, who) {
|
|
385
236
|
const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
|
|
@@ -392,26 +243,10 @@ function validateManifestSchema(manifest, who) {
|
|
|
392
243
|
}
|
|
393
244
|
|
|
394
245
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* regardless of (a) whether a stale manifest_signature is present, (b)
|
|
400
|
-
* key order at any depth, (c) line endings or BOM.
|
|
401
|
-
*
|
|
402
|
-
* 1. Clone, delete manifest_signature.
|
|
403
|
-
* 2. Recursively sort object keys at every depth (NOT the top-level
|
|
404
|
-
* whitelist trap — see codex P1 PR #12: passing
|
|
405
|
-
* `Object.keys(manifest).sort()` as the JSON.stringify replacer-array
|
|
406
|
-
* treats it as a property allowlist applied to EVERY object level.
|
|
407
|
-
* Nested fields like `skills[].path` and `skills[].signature` got
|
|
408
|
-
* silently dropped from the canonical bytes, letting an attacker
|
|
409
|
-
* swap them without breaking the signature. Now we deep-canonicalize
|
|
410
|
-
* every object).
|
|
411
|
-
* 3. Apply normalize() — strip leading BOM, convert CRLF → LF.
|
|
412
|
-
*
|
|
413
|
-
* @param {object} manifest
|
|
414
|
-
* @returns {Buffer} canonical UTF-8 bytes
|
|
246
|
+
* Sort object keys recursively, at every depth. A top-level-only sort — passing
|
|
247
|
+
* `Object.keys(manifest).sort()` as the JSON.stringify replacer array — makes
|
|
248
|
+
* that array a property allowlist applied to EVERY object, dropping
|
|
249
|
+
* `skills[].path` and `skills[].signature` out of the signed bytes.
|
|
415
250
|
*/
|
|
416
251
|
function canonicalize(value) {
|
|
417
252
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
@@ -425,6 +260,8 @@ function canonicalize(value) {
|
|
|
425
260
|
return value;
|
|
426
261
|
}
|
|
427
262
|
|
|
263
|
+
// The signed bytes: identical for the same logical manifest whatever its key
|
|
264
|
+
// order, line endings, or leftover manifest_signature. lib/verify.js matches this.
|
|
428
265
|
function canonicalManifestBytes(manifest) {
|
|
429
266
|
const clone = { ...manifest };
|
|
430
267
|
delete clone.manifest_signature;
|
|
@@ -433,21 +270,10 @@ function canonicalManifestBytes(manifest) {
|
|
|
433
270
|
}
|
|
434
271
|
|
|
435
272
|
/**
|
|
436
|
-
* Sign the canonical manifest bytes
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* The manifest_signature shape carries `algorithm` + `signature_base64`
|
|
441
|
-
* only — no `signed_at` ISO timestamp. A `signed_at` field stripped from
|
|
442
|
-
* the canonical bytes before signing would be unsigned metadata; an
|
|
443
|
-
* attacker who replayed a known-valid signature could rewrite it to any
|
|
444
|
-
* value, lending false freshness authority to a stale signature.
|
|
445
|
-
* Freshness signal lives outside the signed bytes (git-log mtime of
|
|
446
|
-
* manifest.json, npm publish timestamp).
|
|
447
|
-
*
|
|
448
|
-
* @param {object} manifest
|
|
449
|
-
* @param {string} privateKey PEM-encoded Ed25519 private key
|
|
450
|
-
* @returns {{algorithm:'Ed25519', signature_base64:string}}
|
|
273
|
+
* Sign the canonical manifest bytes, returning the manifest's top-level
|
|
274
|
+
* `manifest_signature` object. No `signed_at` in that shape: a timestamp
|
|
275
|
+
* stripped from the bytes before signing is unsigned metadata, so a replayed
|
|
276
|
+
* signature could carry any date and lend false freshness.
|
|
451
277
|
*/
|
|
452
278
|
function signCanonicalManifest(manifest, privateKey) {
|
|
453
279
|
const bytes = canonicalManifestBytes(manifest);
|
|
@@ -462,16 +288,10 @@ function signCanonicalManifest(manifest, privateKey) {
|
|
|
462
288
|
}
|
|
463
289
|
|
|
464
290
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
* inheritance and grants Full Control only to the current user. Any
|
|
470
|
-
* failure (icacls missing, exotic shell, environment without USERNAME)
|
|
471
|
-
* is warned to stderr — generating the key was the load-bearing step,
|
|
472
|
-
* ACL tightening is best-effort hardening.
|
|
473
|
-
*
|
|
474
|
-
* @param {string} targetPath absolute path of the private key file
|
|
291
|
+
* Tighten the Windows ACL on the private key: `mode: 0o600` on win32 only sets
|
|
292
|
+
* the read-only attribute, so the file otherwise inherits the parent's ACL and
|
|
293
|
+
* every desktop user can read it. Returns true off win32; returns false and
|
|
294
|
+
* warns on any failure, which is best-effort hardening, not the key write.
|
|
475
295
|
*/
|
|
476
296
|
function restrictWindowsAcl(targetPath) {
|
|
477
297
|
if (process.platform !== 'win32') return true;
|
|
@@ -515,8 +335,6 @@ function printFingerprintBanner() {
|
|
|
515
335
|
}
|
|
516
336
|
}
|
|
517
337
|
|
|
518
|
-
// --- CLI ---
|
|
519
|
-
|
|
520
338
|
if (require.main === module) {
|
|
521
339
|
const cmd = process.argv[2];
|
|
522
340
|
const arg = process.argv[3];
|
|
@@ -524,12 +342,8 @@ if (require.main === module) {
|
|
|
524
342
|
switch (cmd) {
|
|
525
343
|
case 'generate-keypair': {
|
|
526
344
|
const { aclHardened } = generateKeypair({ rotate: process.argv.includes('--rotate') });
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// that detectable to automation (bootstrap.js, doctor --fix) instead of
|
|
530
|
-
// burying it in a mid-banner line under a 0 exit: fail loud unless the
|
|
531
|
-
// operator opts into the weaker ACL on a host where it is acceptable.
|
|
532
|
-
// exitCode (not process.exit) so the buffered stdout banner drains.
|
|
345
|
+
// A non-zero exit so automation (bootstrap.js, doctor --fix) sees it, not a
|
|
346
|
+
// clean 0. safeExit, not process.exit, so the banner drains.
|
|
533
347
|
if (process.platform === 'win32' && aclHardened === false) {
|
|
534
348
|
if (process.env.EXCEPTD_ALLOW_WEAK_KEY_ACL === '1') {
|
|
535
349
|
console.warn('[sign] WARN: private-key ACL was NOT hardened; continuing because EXCEPTD_ALLOW_WEAK_KEY_ACL=1.');
|