@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,20 +1,8 @@
1
1
  "use strict";
2
2
  /**
3
- * lib/validate-vendor.js
4
- *
5
- * Predeploy gate. Confirms every file recorded in `vendor/blamejs/_PROVENANCE.json`
6
- * has the same SHA-256 on disk as the manifest claims. Silent hand-edits
7
- * to a vendored copy fail the build.
8
- *
9
- * Also confirms vendor/blamejs/LICENSE matches the recorded license hash so
10
- * a license-text change is detected as a separate event from a code change.
11
- *
12
- * Exit codes:
13
- * 0 — vendor tree in sync with provenance
14
- * 1 — at least one file drift / missing
15
- *
16
- * Re-vendor with: copy upstream, apply strip rules, refresh hashes in
17
- * _PROVENANCE.json, re-run this gate.
3
+ * Predeploy gate: every file recorded in `vendor/blamejs/_PROVENANCE.json` must
4
+ * carry the same SHA-256 on disk, and vendor/blamejs/LICENSE must match its
5
+ * recorded hash. Exits 0 when the tree is in sync, 1 on any drift.
18
6
  */
19
7
 
20
8
  const fs = require("fs");
@@ -37,11 +25,8 @@ function main() {
37
25
  const prov = JSON.parse(fs.readFileSync(PROV, "utf8"));
38
26
  const issues = [];
39
27
 
40
- // License file. A recorded license_file with NO license_sha256 is an
41
- // unverifiable integrity claim, not a skip: stripping the hash from the
42
- // manifest must not silently disable the LICENSE-text check (the
43
- // absent-field-fails-open class). Only a manifest that records no
44
- // license_file at all is exempt.
28
+ // A recorded license_file with NO license_sha256 is an unverifiable integrity
29
+ // claim, not a skip: stripping the hash must not disable this check.
45
30
  if (prov.license_file) {
46
31
  if (!prov.license_sha256) {
47
32
  issues.push(`license_file recorded (${prov.license_file}) without license_sha256 — integrity unverifiable`);
@@ -58,17 +43,14 @@ function main() {
58
43
  }
59
44
  }
60
45
 
61
- // Each vendored file.
62
46
  for (const [name, info] of Object.entries(prov.files || {})) {
63
47
  const p = path.join(ROOT, info.vendored_path);
64
48
  if (!fs.existsSync(p)) {
65
49
  issues.push(`missing vendored file: ${info.vendored_path}`);
66
50
  continue;
67
51
  }
68
- // A files[] entry recorded without vendored_sha256 is an unverifiable
69
- // integrity claim — surface it as a clean issue rather than crashing on
70
- // `undefined.slice()` while formatting a drift message (the absent-field
71
- // class, symmetric with the license path above).
52
+ // An entry recorded without vendored_sha256 is an unverifiable integrity
53
+ // claim — report it rather than crash on `undefined.slice()`.
72
54
  if (!info.vendored_sha256) {
73
55
  issues.push(`${info.vendored_path} recorded without vendored_sha256 — integrity unverifiable`);
74
56
  continue;
@@ -77,20 +59,11 @@ function main() {
77
59
  if (live !== info.vendored_sha256) {
78
60
  issues.push(`drift in ${info.vendored_path}: recorded ${info.vendored_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
79
61
  }
80
- // Offline upstream-pin cross-check. The vendored_sha256 compare above is
81
- // self-attesting — it only proves the file matches its OWN recorded hash,
82
- // never that the bytes match blamejs@<pin> upstream. The full upstream
83
- // verification (scripts/validate-vendor-online.js) needs the network and
84
- // is not a predeploy gate, so a hand-edited upstream_sha256_at_pin
85
- // advertising a pin that never existed otherwise passes every
86
- // automatically-run gate.
87
- //
88
- // For a file recorded with NO strip rules, the vendored bytes are
89
- // byte-identical to upstream by definition, so upstream_sha256_at_pin MUST
90
- // equal vendored_sha256. A divergence is a forged or internally
91
- // inconsistent pin claim, and it is provable OFFLINE — no fetch required.
92
- // This closes the upstream side of the integrity check for every
93
- // unmodified vendored file (the common case) inside the existing gate.
62
+ // The vendored_sha256 compare above is self-attesting: it proves the file
63
+ // matches its OWN recorded hash, never that the bytes match blamejs@<pin>.
64
+ // With no strip rules the vendored bytes ARE the upstream bytes, so a
65
+ // divergence from upstream_sha256_at_pin is a forged pin claim, provable
66
+ // offline. The fetching check is scripts/validate-vendor-online.js.
94
67
  const stripped = Array.isArray(info.stripped) ? info.stripped : [];
95
68
  if (stripped.length === 0) {
96
69
  if (!info.upstream_sha256_at_pin) {
@@ -110,16 +83,10 @@ function main() {
110
83
  }
111
84
  }
112
85
 
113
- // On-disk inventory cross-check (BJS-04): the loop above is one-directional
114
- // (manifest → disk). A loadable JS module PRESENT in vendor/blamejs/ but
115
- // ABSENT from _PROVENANCE.json would ship in the tarball and be require()'d
116
- // by lib code while NOTHING verifies its integrity — an unregistered or
117
- // smuggled-in vendored module. Node executes `.js`, `.cjs`, and `.mjs` as
118
- // code, so a `.cjs`/`.mjs` smuggled module carries the same arbitrary-code
119
- // risk as a `.js` one — checking only `.js` let a renamed extension slip the
120
- // gate. `.json`/`.node` are deliberately NOT swept here: `_PROVENANCE.json`
121
- // itself lives in this directory and is the manifest, not a registered
122
- // vendored module, so sweeping `.json` would false-positive on the manifest.
86
+ // The loop above is one-directional (manifest → disk): a module on disk but
87
+ // absent from _PROVENANCE.json ships with nothing verifying its integrity.
88
+ // Node executes `.js`, `.cjs` and `.mjs` alike, so a renamed extension must not
89
+ // slip the sweep; `.json` stays out because `_PROVENANCE.json` IS the manifest.
123
90
  const LOADABLE_MODULE = /\.(c|m)?js$/;
124
91
  const registered = new Set(
125
92
  Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
package/lib/verify.js CHANGED
@@ -1,62 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Skill integrity verifier — Ed25519 cryptographic signatures.
5
- *
6
- * SHA-256 hashes alone protect against accidental corruption; anyone with repo
7
- * write access can update the hash after tampering. Ed25519 signatures prove a
8
- * specific keypair signed each skill. Even if the manifest is updated, a valid
9
- * signature requires the private key, which never enters this repository.
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
- *
33
- * Manifest signature contract (must mirror lib/sign.js):
34
- * The manifest carries a top-level `manifest_signature` field. Before
35
- * iterating skills, loadManifestValidated() extracts the signature,
36
- * recomputes the canonical bytes, and verifies against keys/public.pem.
37
- * On failure, all skill verification is blocked with a structured
38
- * error. When the field is absent (older v0.11.x / pre-v0.12.17
39
- * tarballs in the wild), a warning is emitted but verification
40
- * continues — this preserves backward compatibility for installs that
41
- * predate manifest signing.
42
- *
43
- * Canonical bytes are computed identically to lib/sign.js
44
- * canonicalManifestBytes():
45
- * 1. Clone, delete manifest_signature.
46
- * 2. JSON.stringify with top-level keys sorted lexicographically.
47
- * 3. Apply normalize() — strip BOM, CRLF → LF.
48
- * ANY change to the canonical form requires the matching change in
49
- * lib/sign.js. Round-trip stability is a hard contract.
50
- *
51
- * Signing ceremony: see lib/sign.js
52
- * Public key: keys/public.pem (tracked in repo)
53
- * Private key: .keys/private.pem (gitignored, kept off-repo)
54
- *
55
- * Usage:
56
- * node lib/verify.js — verify all skills
57
- * node lib/verify.js <name> — verify one skill
58
- * node lib/verify.js update — re-sign all skills (requires private key)
59
- * node lib/verify.js check-key — verify the public key is present and valid
4
+ * Skill integrity verifier — Ed25519 signatures over normalized skill content.
60
5
  */
61
6
 
62
7
  const fs = require('fs');
@@ -69,21 +14,10 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
69
14
  const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
70
15
  const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
71
16
  const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
72
- // key-pin file. When present, lib/verify.js compares the live
73
- // public-key fingerprint against the pinned one and fails the verify run
74
- // if they differ (unless the operator sets KEYS_ROTATED=1). The file format
75
- // is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
76
- // shape. The file is OPTIONAL: when missing, the gate warns-and-continues
77
- // rather than failing — this preserves bootstrap compatibility on fresh
78
- // clones / new key ceremonies. Patch-class semantics.
17
+ // Key pin: one line "SHA256:<base64>", compared against live keys/public.pem.
79
18
  const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT');
80
19
 
81
- // --- public API ---
82
-
83
- /**
84
- * Verify all skills in manifest.json against the Ed25519 public key.
85
- * @returns {{ valid: string[], invalid: string[], missing_sig: string[], missing_file: string[], no_key: boolean }}
86
- */
20
+ // Returns per-status name lists; an absent public key returns no_key:true.
87
21
  function verifyAll() {
88
22
  const publicKey = loadPublicKey();
89
23
  if (!publicKey) {
@@ -105,11 +39,7 @@ function verifyAll() {
105
39
  return result;
106
40
  }
107
41
 
108
- /**
109
- * Verify one skill by name.
110
- * @param {string} skillName
111
- * @returns {{ status: string, reason?: string }}
112
- */
42
+ // Throws where verifyAll() reports a status: no public key, or name not in manifest.
113
43
  function verifyOne(skillName) {
114
44
  const publicKey = loadPublicKey();
115
45
  if (!publicKey) throw new Error('No public key at keys/public.pem');
@@ -121,19 +51,13 @@ function verifyOne(skillName) {
121
51
  return verifySkill(skill, publicKey);
122
52
  }
123
53
 
124
- /**
125
- * Re-sign all skills using the private key and write signatures to manifest.json.
126
- * Requires .keys/private.pem — never checked in.
127
- * @returns {{ signed: string[], errors: string[] }}
128
- */
54
+ // Rewrites manifest.json in place. Requires .keys/private.pem.
129
55
  function signAll() {
130
56
  const privateKey = loadPrivateKey();
131
57
  if (!privateKey) throw new Error('No private key at .keys/private.pem — run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js generate-keypair` from a contributor checkout)');
132
58
 
133
- // P1-4: load the manifest without the signature gate. We're about to
134
- // mutate the manifest (re-sign skills + re-sign the manifest itself),
135
- // so a stale manifest_signature mismatch here is expected — not a
136
- // tampering signal. Schema + path validation still apply.
59
+ // Loaded without the signature gate: this run rewrites the signatures, so a
60
+ // stale manifest_signature is expected here. Schema + path checks still run.
137
61
  const manifest = loadManifest();
138
62
  const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
139
63
  const errors0 = validateAgainstSchema(manifest, schema, 'manifest');
@@ -158,20 +82,13 @@ function signAll() {
158
82
  result.signed.push(skill.name);
159
83
  }
160
84
 
161
- // P1-4: re-sign the manifest after the per-skill signatures changed.
162
85
  delete manifest.manifest_signature;
163
86
  const canonical = canonicalManifestBytes(manifest);
164
87
  const manifestSig = crypto.sign(null, canonical, {
165
88
  key: privateKey, dsaEncoding: 'ieee-p1363',
166
89
  });
167
- // `signed_at` is intentionally OMITTED. A `signed_at` timestamp
168
- // alongside the Ed25519 signature would be unsigned metadata (stripped
169
- // from the canonical bytes before signing), so an attacker could replay
170
- // a known-valid signature against the same canonical content while
171
- // rewriting `signed_at` to any value — lending false freshness
172
- // authority to a stale signature. Operators who need a freshness
173
- // signal should consult the git-log mtime of manifest.json (or the
174
- // npm publish timestamp), which are external to the signed bytes.
90
+ // No `signed_at` here: unsigned metadata, so a replayed known-valid signature
91
+ // could carry any timestamp. Freshness comes from git log / npm publish time.
175
92
  manifest.manifest_signature = {
176
93
  algorithm: 'Ed25519',
177
94
  signature_base64: manifestSig.toString('base64'),
@@ -182,46 +99,22 @@ function signAll() {
182
99
  return result;
183
100
  }
184
101
 
185
- // --- private helpers ---
186
-
187
- /**
188
- * Normalize skill content for byte-stable verification.
189
- *
190
- * Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
191
- * line endings to LF. lib/sign.js applies the exact same transform —
192
- * see the byte-stability contract in the file header.
193
- *
194
- * @param {string} content
195
- * @returns {string}
196
- */
102
+ // Strips a leading UTF-8 BOM, then CRLF → LF. lib/sign.js applies the identical
103
+ // transform; diverging breaks the sign→verify round trip for every skill.
197
104
  function normalize(content) {
198
105
  let s = content;
199
106
  if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
200
107
  return s.replace(/\r\n/g, '\n');
201
108
  }
202
109
 
203
- /**
204
- * Validate a manifest skill.path entry to prevent path traversal.
205
- *
206
- * skill.path MUST be a string.
207
- * skill.path MUST start with "skills/".
208
- * skill.path MUST NOT contain "..".
209
- * skill.path MUST NOT contain backslashes.
210
- *
211
- * Same shape as lib/sign.js validateSkillPath(); the two functions
212
- * are intentionally duplicated rather than cross-imported so the
213
- * verify path has no runtime dependency on the sign path.
214
- *
215
- * @param {string} skillPath
216
- * @returns {string}
217
- */
110
+ // Throws unless skillPath is a string under skills/ with no ".." and no
111
+ // backslashes. Deliberately duplicated in lib/sign.js — change both together.
218
112
  function validateSkillPath(skillPath) {
219
113
  if (typeof skillPath !== 'string') {
220
114
  throw new Error(`[verify] manifest skill.path must be a string, got ${typeof skillPath}`);
221
115
  }
222
- // Backslash check runs BEFORE the prefix check so a Windows-style
223
- // path ("skills\foo\skill.md") returns the clearer "use forward
224
- // slashes" diagnostic, not the misleading "must start with skills/".
116
+ // Before the prefix check: a Windows-style "skills\foo\skill.md" earns the
117
+ // "use forward slashes" diagnostic, not the misleading "must start with skills/".
225
118
  if (skillPath.includes('\\')) {
226
119
  throw new Error(`[verify] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
227
120
  }
@@ -293,22 +186,10 @@ function loadManifest() {
293
186
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
294
187
  }
295
188
 
296
- /**
297
- * canonical byte form of the manifest.
298
- *
299
- * Mirrors lib/sign.js canonicalManifestBytes(). Any divergence here
300
- * breaks the verify-after-sign round trip; do not modify in isolation.
301
- *
302
- * v0.12.17 (codex P1 PR #12): use deep canonicalize() instead of the
303
- * top-level sortedKeys replacer-array. The replacer-array form acts as
304
- * a property allowlist applied to EVERY object level — nested fields
305
- * like skills[].path and skills[].signature got silently dropped from
306
- * the canonical bytes, letting an attacker swap them without breaking
307
- * the signature.
308
- *
309
- * @param {object} manifest
310
- * @returns {Buffer} canonical UTF-8 bytes
311
- */
189
+ // Deep key sort, never a top-level JSON.stringify replacer array: a replacer
190
+ // acts as a property allowlist at EVERY object level, dropping skills[].path and
191
+ // skills[].signature out of the signed bytes. Mirrors lib/sign.js
192
+ // canonicalManifestBytes(); diverging breaks the verify-after-sign round trip.
312
193
  function canonicalize(value) {
313
194
  if (Array.isArray(value)) return value.map(canonicalize);
314
195
  if (value && typeof value === 'object') {
@@ -329,31 +210,15 @@ function canonicalManifestBytes(manifest) {
329
210
  }
330
211
 
331
212
  /**
332
- * Verify the top-level manifest_signature against keys/public.pem.
333
- *
334
- * Returns one of:
335
- * { status: 'missing' } — field absent (legacy tarball; warn-but-proceed)
336
- * { status: 'valid' } — signature verifies
337
- * { status: 'invalid', — signature malformed, wrong key, or tampered
338
- * reason: string }
339
- * { status: 'no-key', — keys/public.pem absent
340
- * reason: string }
341
- *
342
- * @param {object} manifest
213
+ * Verify the top-level manifest_signature against keys/public.pem. Status is
214
+ * one of 'valid'; 'missing' (field absent — a caller may warn and proceed);
215
+ * 'invalid' or 'no-key', both carrying a `reason`.
343
216
  */
344
217
  function verifyManifestSignature(manifest) {
345
- // The key-pin fingerprint check runs FIRST — independent of whether a
346
- // manifest_signature is present — so library callers (refresh-network gate,
347
- // verify-shipped-tarball gate, tests, downstream `require("lib/verify")`
348
- // consumers) cannot bypass the pin. Previously the pin only fired AFTER the
349
- // signature-present check, so a key-substitution attacker who swapped
350
- // keys/public.pem AND stripped manifest_signature got the early `missing`
351
- // return and never tripped the pin — authenticating against the attacker key
352
- // through the library API. Consulting the pin up front closes that on the
353
- // legacy/missing path too. Honors KEYS_ROTATED=1 for legitimate rotations; a
354
- // MISSING pin file fails closed (keys/EXPECTED_FINGERPRINT ships in the
355
- // tarball and is committed, so its absence is the signature of a tamper that
356
- // stripped the pin to hide a swapped key).
218
+ // The key pin is consulted FIRST, before the signature-present branch: an
219
+ // attacker who swaps keys/public.pem AND strips manifest_signature would
220
+ // otherwise take the early `missing` return. A missing pin file fails closed —
221
+ // the pin is committed and ships in the tarball. KEYS_ROTATED=1 overrides.
357
222
  const publicKey = loadPublicKey();
358
223
  if (publicKey) {
359
224
  const liveFp = publicKeyFingerprint(publicKey);
@@ -380,12 +245,8 @@ function verifyManifestSignature(manifest) {
380
245
  if (typeof sig.signature_base64 !== 'string') {
381
246
  return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
382
247
  }
383
- // Require the algorithm field to be present and exactly 'Ed25519'.
384
- // Accepting a missing algorithm field
385
- // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) would let a
386
- // downgrade attacker drop the field to bait a weaker default.
387
- // lib/sign.js always writes the field, so no legitimate consumer
388
- // breaks.
248
+ // Exact match, never `sig.algorithm && sig.algorithm !== 'Ed25519'`: tolerating
249
+ // an absent field lets a downgrade attacker drop it. lib/sign.js always writes it.
389
250
  if (sig.algorithm !== 'Ed25519') {
390
251
  return {
391
252
  status: 'invalid',
@@ -395,8 +256,6 @@ function verifyManifestSignature(manifest) {
395
256
  if (!publicKey) {
396
257
  return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
397
258
  }
398
- // The key-pin (mismatch / no-pin) was already verified up front, before any
399
- // signature branching — so reaching here means the live key matches the pin.
400
259
  let signatureBytes;
401
260
  try {
402
261
  signatureBytes = Buffer.from(sig.signature_base64, 'base64');
@@ -417,22 +276,9 @@ function verifyManifestSignature(manifest) {
417
276
  }
418
277
 
419
278
  /**
420
- * Load the manifest and validate it against
421
- * lib/schemas/manifest.schema.json + the path-traversal guard.
422
- *
423
- * Throws on schema violation OR traversal-pattern paths. Either case
424
- * is a fatal-class bug — surface it loudly rather than verify-against-
425
- * a-corrupt-manifest.
426
- *
427
- * also verifies the top-level manifest_signature. On
428
- * invalid signature, throws a structured error blocking all skill
429
- * verification (a coordinated attacker who rewrote manifest.json +
430
- * manifest-snapshot.json + manifest-snapshot.sha256 still cannot forge
431
- * the Ed25519 signature without the private key). When the signature
432
- * field is absent, emits a stderr warning but proceeds — preserves
433
- * backward compatibility for v0.12.16-and-earlier tarballs.
434
- *
435
- * @returns {object}
279
+ * Returns a manifest that passed schema validation, the path-traversal guard and
280
+ * the manifest_signature check, or throws — a verified manifest or nothing. An
281
+ * absent signature field is the sole tolerated case: it warns and proceeds.
436
282
  */
437
283
  function loadManifestValidated() {
438
284
  const manifest = loadManifest();
@@ -449,41 +295,29 @@ function loadManifestValidated() {
449
295
  for (const skill of manifest.skills) {
450
296
  validateSkillPath(skill.path);
451
297
  }
452
- // manifest signature gate. Runs after schema + path
453
- // validation so a malformed manifest reports the structural failure
454
- // before the cryptographic one.
298
+ // After schema + path validation: structural failures report before crypto ones.
455
299
  const sigResult = verifyManifestSignature(manifest);
456
300
  if (sigResult.status === 'invalid') {
457
301
  throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. The manifest has been modified (or signed with a different key) since last sign-all. Refusing to verify any skill against this manifest.`);
458
302
  }
459
303
  if (sigResult.status === 'missing') {
460
- // Dedupe the legacy-tarball warning. Many CLI verbs call
461
- // loadManifestValidated() more than once per invocation, so a plain
462
- // console.warn would spam stderr per call. Node's emitWarning() with
463
- // a stable `code` collapses repeated emissions automatically.
304
+ // emitWarning with a stable `code` collapses repeats; CLI verbs call
305
+ // loadManifestValidated() several times per invocation.
464
306
  process.emitWarning(
465
307
  'manifest.json has no top-level manifest_signature field. This tarball predates v0.12.17 manifest signing; skills will still be verified but a coordinated rewrite of manifest.json could go undetected. Re-run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js sign-all` from a contributor checkout) to add the signature.',
466
308
  { code: 'EXCEPTD_MANIFEST_UNSIGNED' }
467
309
  );
468
310
  } else if (sigResult.status === 'no-key') {
469
- // The documented contract is "return a VERIFIED manifest or throw" — never
470
- // an unverified one. verifyAll()/verifyOne() short-circuit on a missing key
471
- // BEFORE reaching here, so this fires only for a direct
472
- // loadManifestValidated() caller, which must not silently receive an
473
- // unauthenticated manifest: an attacker who deletes keys/public.pem could
474
- // otherwise smuggle a rewritten manifest past a library consumer.
311
+ // verifyAll()/verifyOne() short-circuit on a missing key before reaching
312
+ // here, so this fires only for a direct library caller.
475
313
  throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. Cannot verify the manifest without keys/public.pem; refusing to return an unauthenticated manifest.`);
476
314
  }
477
315
  return manifest;
478
316
  }
479
317
 
480
- // --- JSON schema validator (subset) ---
481
- //
482
- // Mirrors lib/validate-cve-catalog.js's inline validator. Supports the
483
- // schema features manifest.schema.json actually uses: type, required,
484
- // properties, additionalProperties, items, pattern, minLength,
485
- // minItems, $defs / $ref (root-relative only — "#/$defs/foo"). Zero
486
- // external deps.
318
+ // JSON-schema subset, mirroring lib/validate-cve-catalog.js's inline validator:
319
+ // type, required, properties, additionalProperties, items, pattern, minLength,
320
+ // minItems, and root-relative $ref ("#/$defs/foo") only.
487
321
 
488
322
  function typeOf(value) {
489
323
  if (value === null) return 'null';
@@ -580,54 +414,10 @@ function validateAgainstSchema(value, schema, here, root) {
580
414
  }
581
415
 
582
416
  /**
583
- * Public key fingerprint(s) of the DER-encoded SPKI public key,
584
- * base64-encoded. Emits both:
585
- *
586
- * - SHA-256: the universal convention. Matches `ssh-keygen -lf`
587
- * output for the same key, matches GPG / npm provenance / CT log
588
- * fingerprints. Operators cross-referencing the key against an
589
- * external pin will use this line.
590
- *
591
- * - SHA3-512: SHA-3 family (Keccak / sponge construction), different
592
- * mathematical foundation than SHA-2. Hedges against future SHA-2
593
- * weaknesses. 512-bit output (~88 b64 chars) so collision +
594
- * second-preimage resistance both exceed the 256-bit Ed25519 key
595
- * itself. SHA-3 is also the hash family ML-KEM / ML-DSA use
596
- * internally, so this fingerprint travels well with the project's
597
- * PQ posture.
598
- *
599
- * @param {string|null} pemKey PEM-encoded public key (or null)
600
- * @returns {{sha256: string, sha3_512: string}|{error: string}}
601
- */
602
- /**
603
- * compare the live public-key fingerprint against the optional
604
- * pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
605
- * { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
606
- * Callers should warn and continue.
607
- * { status: 'match' } — live fingerprint matches the pin.
608
- * { status: 'mismatch', — divergence; caller should fail unless
609
- * expected, actual, KEYS_ROTATED=1 is set in the environment.
610
- * rotationOverride }
611
- *
612
- * @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
613
- * @param {string} [pinPath] optional override (testability)
614
- */
615
- /**
616
- * Shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file, strips
617
- * a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would otherwise
618
- * prepend U+FEFF and silently break every verify path on the host),
619
- * tolerates CRLF line endings, ignores comment lines (`#`) and blanks,
620
- * and returns the first non-comment / non-empty line. Returns null if
621
- * the file is unreadable / empty.
622
- *
623
- * Shared across five sites so every loader normalises identically:
624
- * - lib/verify.js (manifest signature gate)
625
- * - lib/refresh-network.js (refresh-network pre-swap gate)
626
- * - scripts/verify-shipped-tarball.js (predeploy gate)
627
- * - bin/exceptd.js (attestation pin)
628
- * - lib/prefetch.js (cache-consume index signature pin)
629
- * tests/normalize-contract.test.js asserts byte-identical output across the
630
- * sites under a BOM + CRLF fuzz corpus.
417
+ * First non-comment, non-blank line of keys/EXPECTED_FINGERPRINT, or null when
418
+ * the file is unreadable or empty. Strips a BOM and tolerates CRLF. Every site
419
+ * that reads the pin normalises through here, so a pin that works in one works
420
+ * in all; tests/normalize-contract.test.js asserts that.
631
421
  */
632
422
  function loadExpectedFingerprintFirstLine(pinPath) {
633
423
  let buf;
@@ -636,17 +426,11 @@ function loadExpectedFingerprintFirstLine(pinPath) {
636
426
  if (buf.length >= 2) {
637
427
  const b0 = buf[0];
638
428
  const b1 = buf[1];
639
- // UTF-16LE (FF FE) and UTF-16BE (FE FF) pin files would silently decode
640
- // as UTF-8 mojibake — the first line never matches a live fingerprint
641
- // and the operator sees no signal. Refuse them; re-save as UTF-8.
429
+ // A UTF-16 pin file decodes as UTF-8 mojibake, so the first line silently
430
+ // never matches a live fingerprint. Refuse it; the fix is re-saving as UTF-8.
642
431
  if ((b0 === 0xFF && b1 === 0xFE) || (b0 === 0xFE && b1 === 0xFF)) return null;
643
- // UTF-16BE-without-BOM defense: a pin file saved as UTF-16BE on a host
644
- // whose editor stripped the BOM (or never wrote one) decodes as
645
- // 0x00 <printable ASCII> 0x00 <printable ASCII>... The leading NUL byte
646
- // would survive into the utf8 string and the first-line compare would
647
- // never match a SHA256:... fingerprint. Detect "00 XX" where XX is
648
- // printable ASCII (0x20-0x7E) and refuse — same remediation: re-save
649
- // the file as UTF-8.
432
+ // UTF-16BE whose BOM was stripped: "00 <printable ASCII>" repeating. The
433
+ // leading NUL survives into the string and defeats the compare the same way.
650
434
  if (b0 === 0x00 && b1 >= 0x20 && b1 <= 0x7E) return null;
651
435
  }
652
436
  let raw = buf.toString('utf8');
@@ -658,15 +442,17 @@ function loadExpectedFingerprintFirstLine(pinPath) {
658
442
  return lines[0] || null;
659
443
  }
660
444
 
445
+ // Status is 'no-pin' (no pin file — the caller decides whether that is fatal),
446
+ // 'match', or 'mismatch' with expected/actual and rotationOverride set from
447
+ // KEYS_ROTATED=1. pinPath overrides the default location for tests.
661
448
  function checkExpectedFingerprint(liveFp, pinPath) {
662
449
  const p = pinPath || EXPECTED_FINGERPRINT_PATH;
663
450
  if (!fs.existsSync(p)) return { status: 'no-pin' };
664
451
  if (!liveFp || typeof liveFp.sha256 !== 'string') {
665
452
  return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
666
453
  }
667
- // Route through the shared loader so a BOM-prefixed pin file
668
- // (Notepad with files.encoding=utf8bom) is tolerated identically across
669
- // every verify site. Pre-fix the verbatim split-trim-find produced a
454
+ // The shared loader tolerates a BOM-prefixed pin file identically across every
455
+ // verify site. A verbatim split-trim-find here would instead yield a
670
456
  // allow:bidi-codepoint-literal — illustrative BOM-prefixed first-line in the pin-loader doc comment
671
457
  // first-line of "SHA256:..." (with leading BOM) that would never equal
672
458
  // a live fingerprint.
@@ -695,8 +481,6 @@ function publicKeyFingerprint(pemKey) {
695
481
  }
696
482
  }
697
483
 
698
- // --- CLI ---
699
-
700
484
  if (require.main === module) {
701
485
  const arg = process.argv[2];
702
486
 
@@ -737,14 +521,8 @@ if (require.main === module) {
737
521
  if (result.no_key) process.exit(1);
738
522
 
739
523
  const total = Object.values(result).filter(Array.isArray).flat().length;
740
- // S5 ordering: verdict line first, fingerprint banner after.
741
- // An operator scanning `gh run watch` output should never see a
742
- // fingerprint banner without first seeing whether the verdict
743
- // was pass or fail. The previous order printed the success
744
- // summary then the fingerprint; if verification was actually
745
- // failing (TAMPERED / UNSIGNED / MISSING) the success line was
746
- // never reached but the fingerprint had already been printed,
747
- // which can read as "success" at a glance.
524
+ // Verdict first, fingerprint banner after: a banner printed above a TAMPERED /
525
+ // UNSIGNED / MISSING verdict reads as success at a glance.
748
526
  if (result.invalid.length > 0) {
749
527
  console.error(`\n[verify] ${result.invalid.length}/${total} FAILED — TAMPERED: ${result.invalid.join(', ')}`);
750
528
  } else if (result.missing_sig.length > 0) {
@@ -755,18 +533,13 @@ if (require.main === module) {
755
533
  console.log(`\n[verify] All skills verified. ${result.valid.length}/${total} skills passed Ed25519 verification.`);
756
534
  }
757
535
 
758
- // Fingerprint banner comes AFTER the verdict.
759
536
  const pubKey = loadPublicKey();
760
537
  const fp = publicKeyFingerprint(pubKey);
761
538
  console.log(`[verify] Public key: keys/public.pem`);
762
539
  console.log(`[verify] ${fp.sha256}`);
763
540
  console.log(`[verify] ${fp.sha3_512}`);
764
541
 
765
- // pin check. When keys/EXPECTED_FINGERPRINT exists, the
766
- // live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
767
- // intentionally override). When the file is absent, emit a single-line
768
- // warning but continue — fresh clones / bootstrap workflows should not
769
- // fail the gate before the operator has committed a fingerprint.
542
+ // On this path an absent pin warns and continues: a fresh clone has no pin yet.
770
543
  const pinResult = checkExpectedFingerprint(fp);
771
544
  if (pinResult.status === 'no-pin') {
772
545
  console.warn(
@@ -780,10 +553,7 @@ if (require.main === module) {
780
553
  `KEYS_ROTATED=1 accepted. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
781
554
  { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
782
555
  );
783
- // Mirror to stderr unconditionally: NODE_NO_WARNINGS=1 silences
784
- // process.emitWarning, but a key-rotation override is a
785
- // security-relevant event that must surface in the operator's
786
- // terminal even when warnings are muted.
556
+ // Mirrored to stderr: NODE_NO_WARNINGS=1 silences emitWarning.
787
557
  console.error(
788
558
  `[verify] KEYS_ROTATED=1 override accepted; live fingerprint ${pinResult.actual} ` +
789
559
  `differs from pin ${pinResult.expected}. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`