@blamejs/exceptd-skills 0.18.11 → 0.18.12

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.12 — 2026-06-22
4
+
5
+ A correctness pass across the CLI, the engine, scoring, the collectors, framework-gap reporting, signature verification, and the CWE-chain index.
6
+
7
+ `ci`/`run --evidence-dir` now refuses a `<playbook>.json` entry that is not a JSON object (an array, scalar, or null) with the same object-shape error the single-file and stdin paths use, instead of binding it as empty evidence and reporting a clean `not_detected` PASS at exit 0 — a mis-shaped per-playbook submission no longer produces a false-clean gate result.
8
+
9
+ `verifyManifestSignature` consults the `keys/EXPECTED_FINGERPRINT` key pin before the missing-signature path, so a swapped `keys/public.pem` whose `manifest_signature` was stripped is rejected through the library API rather than treated as a benign legacy state. Escalation and `feeds_into` ordering comparisons against a duration literal — e.g. the kernel playbook's `reboot_window > 24h` raise-severity chain — normalize both sides to hours and compare numerically instead of lexicographically (so a 48-hour window escalates and a 6-hour window does not); an ordering comparison that degrades to two non-numeric, non-duration strings now surfaces a `condition_type_mismatch` diagnostic instead of evaluating silently.
10
+
11
+ `compare()`'s factor explanation lists the reboot (+5) driver whenever a reboot is required, regardless of live-patch availability, matching the score it actually computes; and a post-weight RWEP block that stores `active_exploitation` as a status string is read as a post-weight block (its weighted factors, including the AI factor, are no longer dropped or mis-flagged as a mixed shape).
12
+
13
+ The secrets collector's `ssh-key-bad-perms` posture and the credential-store AWS doc-fixture demotion no longer false-positive on checked-in test-path fixtures or a duplicate profile name. A single-framework `framework-gap` report scopes its theater-risk list and matching-gap count to the requested framework instead of leaking controls from frameworks the operator did not ask about. `rfc --check` no longer falsely matches an unrelated title when the claimed title repeats a token. The CWE-chains index excludes auto-imported draft CVEs, matching the by-CVE half's curated-truth invariant. A null or non-object MCP server entry (config scan) or dispatch finding is skipped with a clear marker rather than dropping the file's other findings or throwing an opaque error.
14
+
3
15
  ## 0.18.11 — 2026-06-22
4
16
 
5
17
  Regenerates the CycloneDX SBOM (`sbom.cdx.json`) so its recorded hash for `CHANGELOG.md` matches the shipped file.
package/bin/exceptd.js CHANGED
@@ -4339,12 +4339,19 @@ function readEvidenceDir(dir, verb) {
4339
4339
  extra: { entry: f, resolved_to: realEntry },
4340
4340
  };
4341
4341
  }
4342
- bundle[pbId] = JSON.parse(raw);
4342
+ // Apply the SAME object-shape guard the single-file / stdin path uses
4343
+ // (asEvidenceObject): a `<pb>.json` that parses to an array, scalar, or
4344
+ // null is not a valid evidence document. Without this an mis-shaped entry
4345
+ // was bound verbatim and ran downstream as empty evidence, yielding a
4346
+ // false-clean `not_detected` PASS at exit 0 — the exact hole the
4347
+ // single-file guard closes.
4348
+ bundle[pbId] = asEvidenceObject(JSON.parse(raw));
4343
4349
  } catch (e) {
4344
- // A refusal object thrown by JSON.parse / readFileSync lands here; surface
4345
- // it with the entry name. (The explicit refusals above return directly and
4346
- // never reach this catch.)
4347
- return { ok: false, error: `${verb}: failed to read --evidence-dir entry ${f}: ${e.message}`, extra: null };
4350
+ // A JSON parse error or the asEvidenceObject shape refusal lands here;
4351
+ // surface it with the entry name so the operator sees the real reason
4352
+ // (e.g. "evidence must be a JSON object"). The explicit symlink/junction
4353
+ // refusals above return directly and never reach this catch.
4354
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f}: ${e.message}`, extra: { entry: f } };
4348
4355
  } finally {
4349
4356
  try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
4350
4357
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-22T04:26:25.259Z",
3
+ "generated_at": "2026-06-22T06:27:15.592Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "c1c37465664024760c34e6c236592ee071e5347d3fb736dba3a495fee6768126",
7
+ "manifest.json": "d897efd048acdde209213af36857c5385c036a650096531408ca978590e38226",
8
8
  "README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
9
9
  "data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
10
10
  "data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
@@ -57,7 +57,7 @@ function fileExists(full) {
57
57
  // AWS credentials INI: any [profile] block carrying
58
58
  // `aws_access_key_id` AND no `sso_session` / `credential_process`.
59
59
  function parseAwsCredentials(content) {
60
- if (!content) return { staticProfiles: [], federatedProfiles: [] };
60
+ if (!content) return { staticProfiles: [], federatedProfiles: [], staticKeys: {} };
61
61
  const lines = content.split(/\r?\n/);
62
62
  const profiles = {};
63
63
  let current = null;
@@ -77,13 +77,21 @@ function parseAwsCredentials(content) {
77
77
  }
78
78
  const staticProfiles = [];
79
79
  const federatedProfiles = [];
80
+ // Per-static-profile aws_access_key_id, so doc-fixture demotion can key off
81
+ // the exact parsed value instead of re-finding the first name-matching block.
82
+ // A duplicate profile name resolves to the LAST occurrence's keys here, which
83
+ // is the same precedence the AWS SDK applies.
84
+ const staticKeys = {};
80
85
  for (const [name, kv] of Object.entries(profiles)) {
81
86
  const hasKey = !!kv["aws_access_key_id"];
82
87
  const hasFederation = !!(kv["sso_session"] || kv["credential_process"] || kv["role_arn"]);
83
- if (hasKey && !hasFederation) staticProfiles.push(name);
88
+ if (hasKey && !hasFederation) {
89
+ staticProfiles.push(name);
90
+ staticKeys[name] = kv["aws_access_key_id"];
91
+ }
84
92
  if (hasFederation) federatedProfiles.push(name);
85
93
  }
86
- return { staticProfiles, federatedProfiles };
94
+ return { staticProfiles, federatedProfiles, staticKeys };
87
95
  }
88
96
 
89
97
  // kubeconfig: users[].user.token field present (non-empty) with no
@@ -247,12 +255,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
247
255
  // unsatisfied, which is the honest outcome.
248
256
  const AWS_DOC_FIXTURE_KEY = "AKIAIOSFODNN7EXAMPLE";
249
257
  const realAwsProfiles = awsCredsParsed.staticProfiles.filter(p => {
250
- // Parse the raw INI again for this profile's key value + name.
251
- // For doc-fixture demotion (FP[0]) we look up the key value; for
252
- // break-glass demotion (FP[2]) we check the profile name pattern.
253
- const block = (awsCredsContent || "").split(/^\[/m).find(b => b.startsWith(p + "]"));
254
- if (!block) return true;
255
- if (block.includes(AWS_DOC_FIXTURE_KEY)) return false; // FP[0]
258
+ // Demote off this profile's EXACT parsed key value (FP[0]) and its name
259
+ // (FP[2]). Keying off the parsed value — not the first raw block whose
260
+ // name matches — means a duplicate profile name whose first occurrence
261
+ // holds the doc-fixture key cannot demote the later real key under the
262
+ // same name (the parser resolves the live last-occurrence value).
263
+ const keyVal = awsCredsParsed.staticKeys[p];
264
+ if (keyVal === AWS_DOC_FIXTURE_KEY) return false; // FP[0]
256
265
  if (/^breakglass-/i.test(p) || /^break-glass-/i.test(p)) return false; // FP[2]
257
266
  return true;
258
267
  });
@@ -495,8 +495,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
495
495
  // any private-key file with mode != 0600
496
496
  // The collector scope is the cwd; ~/.ssh enumeration is outside this
497
497
  // walk root. Within cwd, flag any discovered private key whose mode
498
- // is anything other than 0600 (strict).
499
- const sshKeyPostures = process.platform === "win32" ? [] : sshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
498
+ // is anything other than 0600 (strict). Use the test-path-filtered set
499
+ // (prodSshPrivateKeys) — matching ssh-private-key-block — so a fixture
500
+ // key checked in under a test/ path doesn't raise a bad-perms posture.
501
+ const sshKeyPostures = process.platform === "win32" ? [] : prodSshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
500
502
  signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
501
503
 
502
504
  // Per-indicator file locations for every indicator flipped to "hit", so
@@ -211,6 +211,24 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
211
211
  };
212
212
  }
213
213
 
214
+ // Scope the report to what the operator actually requested. With an explicit
215
+ // framework filter, only gaps that survived the per-framework filter
216
+ // (frameworkResults[*].gaps) belong in the report; with `all`, every
217
+ // scenario-relevant gap does. `seen` is the de-duplicated set of surviving
218
+ // gap keys (a single gap can match multiple requested frameworks). Both
219
+ // theater_risks and the matching count derive from it so the per-framework
220
+ // body, the theater-risk list, and the summary footer all agree.
221
+ let scopedGaps;
222
+ if (opts.allFrameworks) {
223
+ scopedGaps = relevantGaps;
224
+ } else {
225
+ const seen = new Set();
226
+ for (const id of frameworkIds) {
227
+ for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
228
+ }
229
+ scopedGaps = relevantGaps.filter(([key]) => seen.has(key));
230
+ }
231
+
214
232
  // Cycle 20 A P1 (v0.12.40): pre-fix this filtered on `theater_pattern`
215
233
  // (a legacy field) but the v0.12.29 backfill added a structured
216
234
  // `theater_test` block on all 118 entries while leaving most without
@@ -220,7 +238,11 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
220
238
  // the legacy field. Now: an entry is theater-risk if it's open AND
221
239
  // carries EITHER `theater_test` OR `theater_pattern`. Footer + badge
222
240
  // count agree.
223
- const theaterRisks = relevantGaps
241
+ //
242
+ // theater_risks is built from scopedGaps (not the full relevantGaps) so a
243
+ // single-framework request cannot leak or mis-summarize theater controls
244
+ // from frameworks the operator never asked about.
245
+ const theaterRisks = scopedGaps
224
246
  .filter(([, g]) => g.status === 'open' && (g.theater_test || g.theater_pattern))
225
247
  .map(([key, g]) => ({
226
248
  control: key,
@@ -234,19 +256,8 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
234
256
  // explicit framework filter the summary must agree with the per-framework
235
257
  // body the operator actually sees — otherwise `framework-gap nist-800-53
236
258
  // <cve>` shows e.g. "2 matching control gap(s)" per-framework but "Summary:
237
- // 8 matching gaps" (every framework's hits, pre-filter). Sum the per-
238
- // framework gap_count so body + summary agree. De-duplicate by gap key in
239
- // case a single gap matches multiple requested frameworks.
240
- let matchingGapCount;
241
- if (opts.allFrameworks) {
242
- matchingGapCount = relevantGaps.length;
243
- } else {
244
- const seen = new Set();
245
- for (const id of frameworkIds) {
246
- for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
247
- }
248
- matchingGapCount = seen.size;
249
- }
259
+ // 8 matching gaps" (every framework's hits, pre-filter).
260
+ const matchingGapCount = scopedGaps.length;
250
261
 
251
262
  return {
252
263
  threat_scenario: threatScenario,
@@ -4204,8 +4204,44 @@ function evalCondition(expr, ctx, playbook) {
4204
4204
  // would exclude it: 'critical' < 'high' lexicographically).
4205
4205
  const SEV = { low: 0, medium: 1, high: 2, critical: 3 };
4206
4206
  const lr = SEV[String(lv).toLowerCase()], rr = SEV[String(rv).toLowerCase()];
4207
- const a = (lr !== undefined && rr !== undefined) ? lr : lv;
4208
- const b = (lr !== undefined && rr !== undefined) ? rr : rv;
4207
+ let a = (lr !== undefined && rr !== undefined) ? lr : lv;
4208
+ let b = (lr !== undefined && rr !== undefined) ? rr : rv;
4209
+ const isOrdering = op === '>=' || op === '<=' || op === '>' || op === '<';
4210
+ if (isOrdering && (lr === undefined || rr === undefined)) {
4211
+ // Duration literals carry a unit suffix (`24h`, `7d`, `30min`) — the
4212
+ // catalog writes ordering comparisons against them (kernel.json's
4213
+ // `reboot_window > 24h` raise_severity escalation). The RHS-coercion
4214
+ // above only converts a BARE numeric (`/^-?\d+(\.\d+)?$/`), so a unit-
4215
+ // suffixed literal stays a string. A numeric LHS then compares against a
4216
+ // string RHS (`48 > '24h'` → `48 > NaN` → false: a 48h window silently
4217
+ // fails to escalate) and a string LHS compares lexicographically
4218
+ // (`'6h' > '24h'` → `'6' > '2'` → true: a 6h window WRONGLY escalates).
4219
+ // Normalize both sides to canonical hours when a duration unit appears on
4220
+ // either side: a unit-suffixed literal converts by its unit family; a
4221
+ // bare number is taken in the same family as the duration it is compared
4222
+ // against (hours-equivalent magnitude). The comparison is then numeric.
4223
+ const la = parseDurationHours(a), ba = parseDurationHours(b);
4224
+ if ((la !== null || ba !== null) && la !== null && ba !== null) {
4225
+ a = la; b = ba;
4226
+ } else if (
4227
+ // Two non-numeric, non-severity, non-duration strings under an ordering
4228
+ // operator is a silently-degraded comparison (lexicographic / NaN) — the
4229
+ // clause PARSED so condition_unparsed never fires. Surface a distinct
4230
+ // condition_type_mismatch so the degraded comparison is observable
4231
+ // (the boolean result is unchanged; this is diagnostics only).
4232
+ typeof a !== 'number' && typeof b !== 'number' &&
4233
+ !(typeof a === 'string' && /^-?\d+(?:\.\d+)?$/.test(a.trim())) &&
4234
+ !(typeof b === 'string' && /^-?\d+(?:\.\d+)?$/.test(b.trim()))
4235
+ ) {
4236
+ const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
4237
+ : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
4238
+ : null;
4239
+ if (target) {
4240
+ pushRunError(target, { kind: 'condition_type_mismatch', condition: String(expr).slice(0, 200) },
4241
+ { dedupeKey: x => x.condition || '' });
4242
+ }
4243
+ }
4244
+ }
4209
4245
  switch (op) {
4210
4246
  case '==': case '=': return lv == rv;
4211
4247
  case '!=': return lv != rv;
@@ -4346,6 +4382,33 @@ function resolvePath(obj, dot) {
4346
4382
  return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
4347
4383
  }
4348
4384
 
4385
+ /**
4386
+ * Normalize a duration operand to canonical hours for a numeric comparison.
4387
+ * Accepts a unit-suffixed literal (`24h`, `7d`, `2wk`, `30min`) and converts by
4388
+ * its unit family, OR a bare number / numeric string (returned as its own
4389
+ * magnitude — the catalog writes `reboot_window > 24h` where the LHS resolves to
4390
+ * a bare hour count). Returns null for anything that is not a recognized
4391
+ * duration or plain number, so the caller can detect that BOTH sides normalized
4392
+ * before comparing numerically (and surface a type-mismatch otherwise).
4393
+ */
4394
+ const DURATION_UNIT_HOURS = {
4395
+ h: 1, hr: 1, hrs: 1,
4396
+ m: 1 / 60, min: 1 / 60,
4397
+ d: 24, day: 24, days: 24,
4398
+ w: 168, wk: 168,
4399
+ };
4400
+ function parseDurationHours(v) {
4401
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
4402
+ if (typeof v !== 'string') return null;
4403
+ const s = v.trim();
4404
+ // Bare numeric string (no unit) — take its magnitude as-is.
4405
+ if (/^-?\d+(?:\.\d+)?$/.test(s)) return parseFloat(s);
4406
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|d|day|days|wk|w|m|min)$/i);
4407
+ if (!m) return null;
4408
+ const mult = DURATION_UNIT_HOURS[m[2].toLowerCase()];
4409
+ return mult === undefined ? null : parseFloat(m[1]) * mult;
4410
+ }
4411
+
4349
4412
  /**
4350
4413
  * Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
4351
4414
  * surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
package/lib/rfc-cli.js CHANGED
@@ -86,8 +86,13 @@ function titleMatches(claimed, indexTitle) {
86
86
  // No contiguous run, but all tokens present out of order. Accept only when the
87
87
  // claim covers a strong majority of the index title's tokens (containment
88
88
  // ratio floor) — a few scattered tokens against a long title is ambiguous,
89
- // not a match.
90
- const ratio = claimTokens.length / titleTokens.length;
89
+ // not a match. Count DISTINCT claim tokens that appear in the title: counting
90
+ // non-distinct tokens lets a repeated-token claim (e.g. "security security
91
+ // security security") inflate the ratio past the floor and falsely match an
92
+ // unrelated title.
93
+ const distinct = new Set(claimTokens);
94
+ const present = [...distinct].filter((t) => titleSet.has(t)).length;
95
+ const ratio = present / titleTokens.length;
91
96
  return ratio >= 0.8;
92
97
  }
93
98
 
package/lib/scoring.js CHANGED
@@ -367,13 +367,29 @@ function scoreCustom(factors, opts) {
367
367
  */
368
368
  function deriveRwepFromFactors(factors) {
369
369
  if (!factors || typeof factors !== 'object') return 0;
370
- const values = Object.values(factors);
371
- if (values.length === 0) return 0;
370
+ const entries = Object.entries(factors);
371
+ if (entries.length === 0) return 0;
372
+ // A boolean factor OR a string active_exploitation ladder value is Shape-A
373
+ // evidence — scoreCustom reads exactly those. active_exploitation's string
374
+ // form legitimately appears in BOTH shapes (Shape A stores it as the literal
375
+ // ladder string; a Shape B post-weight block can ALSO carry it as a
376
+ // human-readable status alongside its post-weight integers), so it is the
377
+ // hasPostWeightInt guard below — NOT excluding active_exploitation from this
378
+ // check — that disambiguates them. Excluding it here under-scored an
379
+ // active-exploitation-ONLY raw bag (e.g. `{ active_exploitation: 'confirmed',
380
+ // blast_radius: 10 }`): hasBooleanOrLadder went false, the block fell through
381
+ // to the Shape-B sum, and the ladder string was skipped (10 vs scoreCustom 30).
372
382
  const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
373
- const hasBooleanOrLadder = values.some(
374
- (v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase())),
383
+ const hasBooleanOrLadder = entries.some(
384
+ ([, v]) => (typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase()))),
375
385
  );
376
- if (hasBooleanOrLadder) {
386
+ // A boolean-named key carrying a post-weight integer (>=5) is unambiguous
387
+ // Shape-B evidence. When present, the block is Shape B even if it also carries
388
+ // a string active_exploitation — route to the post-weight sum, not scoreCustom.
389
+ const hasPostWeightInt = entries.some(
390
+ ([k, v]) => k !== 'blast_radius' && typeof v === 'number' && Number.isFinite(v) && Math.abs(v) >= 5,
391
+ );
392
+ if (hasBooleanOrLadder && !hasPostWeightInt) {
377
393
  return scoreCustom(factors);
378
394
  }
379
395
  // Shape B: catalog post-weight. Sum + clamp.
@@ -505,7 +521,14 @@ function compare(cveId, catalog, opts) {
505
521
  if (entry.poc_available) driving.push('public PoC (+20)');
506
522
  if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
507
523
  if (String(entry.active_exploitation || '').trim().toLowerCase() === 'confirmed') driving.push('confirmed exploitation (+20)');
508
- if ((entry.reboot_required || entry.patch_required_reboot) && !entry.live_patch_available) driving.push('reboot required (+5)');
524
+ // Mirror scoreCustom's rebootFactor EXACTLY: the +5 reboot weight is added
525
+ // whenever a reboot is required, regardless of live_patch_available (a live
526
+ // patch is a temporary workaround; the full-remediation window still extends
527
+ // — see the RWEP_WEIGHTS header note). Gating this driver on
528
+ // !live_patch_available made the enumerated factors sum to less than the
529
+ // delta on any entry that both requires a reboot AND has a live patch
530
+ // available, hiding a driver the score actually counted.
531
+ if (entry.reboot_required || entry.patch_required_reboot) driving.push('reboot required (+5)');
509
532
  explanation += driving.join(', ');
510
533
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
511
534
  } else if (delta < -10) {
@@ -569,6 +592,16 @@ function detectFactorShape(factors) {
569
592
  let sawWeightedInt = false;
570
593
  for (const [k, v] of Object.entries(factors)) {
571
594
  if (k === 'blast_radius') continue; // always integer in both shapes
595
+ if (k === 'active_exploitation' && typeof v === 'string') {
596
+ // active_exploitation's string-ladder form is valid in BOTH shapes — a
597
+ // Shape B (post-weight) block can carry it as the human-readable status
598
+ // string alongside its post-weight integers, exactly the way Shape A does.
599
+ // So a string active_exploitation is NOT Shape-A evidence; counting it as
600
+ // sawBool produced a spurious 'mixed' verdict (and a validate() error) on
601
+ // an otherwise-clean Shape B block. Its weight, when summed, is resolved
602
+ // via resolveActiveExploitation in the post-weight path, not here.
603
+ continue;
604
+ }
572
605
  if (typeof v === 'boolean' || v === null) {
573
606
  sawBool = true;
574
607
  } else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
@@ -579,7 +612,7 @@ function detectFactorShape(factors) {
579
612
  // 0/1 on a boolean-named field could be either shape; ambiguous, ignore.
580
613
  continue;
581
614
  } else if (typeof v === 'string' && boolFields.includes(k)) {
582
- // String values (e.g. active_exploitation: 'confirmed') are Shape A.
615
+ // String values on OTHER boolean-named fields are Shape A.
583
616
  sawBool = true;
584
617
  }
585
618
  }
package/lib/verify.js CHANGED
@@ -342,6 +342,39 @@ function canonicalManifestBytes(manifest) {
342
342
  * @param {object} manifest
343
343
  */
344
344
  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).
357
+ const publicKey = loadPublicKey();
358
+ if (publicKey) {
359
+ const liveFp = publicKeyFingerprint(publicKey);
360
+ const pinResult = checkExpectedFingerprint(liveFp);
361
+ if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
362
+ return {
363
+ status: 'invalid',
364
+ reason: `fingerprint-mismatch: live=${pinResult.actual} pin=${pinResult.expected} — keys/public.pem does not match keys/EXPECTED_FINGERPRINT. If this is an intentional rotation, set KEYS_ROTATED=1 and update the pin.`,
365
+ fingerprint_mismatch: true,
366
+ expected: pinResult.expected,
367
+ actual: pinResult.actual,
368
+ };
369
+ }
370
+ if (pinResult.status === 'no-pin') {
371
+ return {
372
+ status: 'invalid',
373
+ 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.`,
374
+ pin_absent: true,
375
+ };
376
+ }
377
+ }
345
378
  const sig = manifest && manifest.manifest_signature;
346
379
  if (!sig || typeof sig !== 'object') return { status: 'missing' };
347
380
  if (typeof sig.signature_base64 !== 'string') {
@@ -359,43 +392,11 @@ function verifyManifestSignature(manifest) {
359
392
  reason: `manifest_signature.algorithm must be exactly 'Ed25519' (got ${JSON.stringify(sig.algorithm)})`,
360
393
  };
361
394
  }
362
- const publicKey = loadPublicKey();
363
395
  if (!publicKey) {
364
396
  return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
365
397
  }
366
- // consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify so
367
- // library callers (refresh-network gate, verify-shipped-tarball gate, tests,
368
- // downstream consumers via `require("lib/verify")`) cannot bypass the pin.
369
- // Previously the pin only fired at the CLI tail of `node lib/verify.js`,
370
- // letting a coordinated attacker who swapped keys/public.pem authenticate
371
- // against the attacker key without any divergence surfaced through the
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.
377
- const liveFp = publicKeyFingerprint(publicKey);
378
- const pinResult = checkExpectedFingerprint(liveFp);
379
- if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
380
- return {
381
- status: 'invalid',
382
- reason: `fingerprint-mismatch: live=${pinResult.actual} pin=${pinResult.expected} — keys/public.pem does not match keys/EXPECTED_FINGERPRINT. If this is an intentional rotation, set KEYS_ROTATED=1 and update the pin.`,
383
- fingerprint_mismatch: true,
384
- expected: pinResult.expected,
385
- actual: pinResult.actual,
386
- };
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
- }
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.
399
400
  let signatureBytes;
400
401
  try {
401
402
  signatureBytes = Buffer.from(sig.signature_base64, 'base64');