@blamejs/exceptd-skills 0.18.7 → 0.18.9

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