@blamejs/exceptd-skills 0.12.22 → 0.12.24

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 (47) hide show
  1. package/AGENTS.md +18 -12
  2. package/ARCHITECTURE.md +2 -2
  3. package/CHANGELOG.md +152 -2
  4. package/CONTEXT.md +126 -69
  5. package/README.md +21 -8
  6. package/bin/exceptd.js +972 -464
  7. package/data/_indexes/_meta.json +3 -3
  8. package/data/_indexes/stale-content.json +10 -3
  9. package/data/playbooks/ai-api.json +1 -1
  10. package/data/playbooks/containers.json +1 -1
  11. package/data/playbooks/cred-stores.json +1 -1
  12. package/data/playbooks/crypto-codebase.json +1 -1
  13. package/data/playbooks/crypto.json +1 -1
  14. package/data/playbooks/framework.json +1 -1
  15. package/data/playbooks/hardening.json +1 -1
  16. package/data/playbooks/kernel.json +1 -1
  17. package/data/playbooks/library-author.json +1 -1
  18. package/data/playbooks/mcp.json +1 -1
  19. package/data/playbooks/runtime.json +1 -1
  20. package/data/playbooks/sbom.json +1 -1
  21. package/data/playbooks/secrets.json +39 -1
  22. package/lib/auto-discovery.js +28 -4
  23. package/lib/cross-ref-api.js +12 -11
  24. package/lib/cve-curation.js +18 -19
  25. package/lib/exit-codes.js +72 -0
  26. package/lib/flag-suggest.js +130 -0
  27. package/lib/id-validation.js +95 -0
  28. package/lib/lint-skills.js +73 -6
  29. package/lib/playbook-runner.js +617 -343
  30. package/lib/prefetch.js +134 -21
  31. package/lib/refresh-external.js +205 -26
  32. package/lib/refresh-network.js +64 -16
  33. package/lib/schemas/cve-catalog.schema.json +7 -1
  34. package/lib/schemas/playbook.schema.json +51 -0
  35. package/lib/scoring.js +49 -7
  36. package/lib/sign.js +10 -11
  37. package/lib/source-osv.js +7 -7
  38. package/lib/upstream-check-cli.js +16 -1
  39. package/lib/upstream-check.js +9 -0
  40. package/lib/validate-catalog-meta.js +1 -1
  41. package/lib/validate-cve-catalog.js +1 -1
  42. package/lib/verify.js +56 -30
  43. package/manifest.json +40 -40
  44. package/package.json +8 -2
  45. package/sbom.cdx.json +6 -6
  46. package/scripts/check-test-coverage.js +67 -0
  47. package/scripts/verify-shipped-tarball.js +27 -18
@@ -6,6 +6,57 @@
6
6
  "type": "object",
7
7
  "required": ["_meta", "domain", "phases", "directives"],
8
8
  "additionalProperties": false,
9
+ "allOf": [
10
+ {
11
+ "if": {
12
+ "type": "object",
13
+ "properties": {
14
+ "_meta": {
15
+ "type": "object",
16
+ "properties": { "air_gap_mode": { "const": true } },
17
+ "required": ["air_gap_mode"]
18
+ }
19
+ },
20
+ "required": ["_meta"]
21
+ },
22
+ "then": {
23
+ "description": "When _meta.air_gap_mode is true, every artifact whose source contains a network-call substring (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry an air_gap_alternative. The runner refuses to use the network when --air-gap is set, so an artifact with no offline fallback cannot be collected and the run is incomplete.",
24
+ "type": "object",
25
+ "properties": {
26
+ "phases": {
27
+ "type": "object",
28
+ "properties": {
29
+ "look": {
30
+ "type": "object",
31
+ "properties": {
32
+ "artifacts": {
33
+ "type": "array",
34
+ "items": {
35
+ "anyOf": [
36
+ {
37
+ "not": {
38
+ "type": "object",
39
+ "properties": {
40
+ "source": {
41
+ "type": "string",
42
+ "pattern": "(https://|http://|gh api|gh release|curl |wget |fetch )"
43
+ }
44
+ },
45
+ "required": ["source"]
46
+ }
47
+ },
48
+ { "required": ["air_gap_alternative"] }
49
+ ]
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ],
9
60
  "properties": {
10
61
 
11
62
  "_meta": {
package/lib/scoring.js CHANGED
@@ -92,7 +92,7 @@ function score(cveId, catalog) {
92
92
  }
93
93
 
94
94
  /**
95
- * E10: Validate an RWEP factor bag. Returns an array of warning strings
95
+ * Validate an RWEP factor bag. Returns an array of warning strings
96
96
  * for missing-but-defaultable fields and out-of-range values. Does NOT
97
97
  * throw — operators wanting hard enforcement should treat a non-empty
98
98
  * return as a failure themselves.
@@ -342,13 +342,14 @@ function validate(catalog) {
342
342
  const errors = [];
343
343
  for (const [cveId, entry] of Object.entries(catalog)) {
344
344
  if (cveId.startsWith('_')) continue;
345
- // FF P1-1: skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts
346
- // store a conservative-default rwep_score (poc=true, reboot=true, etc.)
345
+ // Skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts store a
346
+ // conservative-default rwep_score (poc=true, reboot=true, etc.)
347
347
  // alongside `poc_available: null` and other null-until-curated factor
348
- // fields, so the recomputed-vs-stored divergence check ALWAYS fires
349
- // against them flooding the predeploy gate. Drafts are reviewed
350
- // separately via the `_auto_imported_meta.curation_needed` list and the
351
- // strict catalog validator's draft-warning tier. Once curation promotes
348
+ // fields, so the recomputed-vs-stored divergence check would always
349
+ // fire against them and flood the predeploy gate. Drafts are reviewed
350
+ // separately via the `_auto_imported_meta.curation_needed` list and
351
+ // the strict catalog validator's draft-warning tier. Once curation
352
+ // promotes
352
353
  // an entry, `_auto_imported` is cleared and full validation resumes.
353
354
  if (entry && entry._auto_imported === true) continue;
354
355
  for (const field of CVE_SCHEMA_REQUIRED) {
@@ -380,6 +381,46 @@ function validate(catalog) {
380
381
  return errors;
381
382
  }
382
383
 
384
+ /**
385
+ * Strict CVSS 3.1 vector parse. Returns `{ ok, version, reason? }`.
386
+ *
387
+ * The CSAF 2.0 cvss_v3 score block requires a canonical CVSS 3.1 vector
388
+ * string. Strict validators (BSI CSAF Validator, ENISA dashboard) reject
389
+ * documents that emit a cvss_v3 block keyed off a malformed vector — the
390
+ * pre-fix permissive `^CVSS:(\d+\.\d+)/` regex let through 3.0 vectors,
391
+ * truncated metric sets, and unknown environmental-metric values, which
392
+ * downstream tooling then rejected wholesale.
393
+ *
394
+ * Required metric set (in order): AV / AC / PR / UI / S / C / I / A.
395
+ * Optional temporal metrics: E / RL / RC.
396
+ * Optional environmental metrics: CR / IR / AR / MAV / MAC / MPR / MUI /
397
+ * MS / MC / MI / MA.
398
+ */
399
+ // CVSS 3.0 and 3.1 share an identical vector grammar (metric set, value enums,
400
+ // and metric order are the same; only the `CVSS:X.Y/` prefix differs). CSAF
401
+ // 2.0 §3.2.4.3 accepts both versions in the cvss_v3 block. The strict regex
402
+ // matches either prefix; the parser records which version the vector declared
403
+ // so the emitter can stamp the right `version` field.
404
+ const CVSS_3X_RE = /^CVSS:3\.[01]\/AV:[NALP]\/AC:[LH]\/PR:[NLH]\/UI:[NR]\/S:[UC]\/C:[NLH]\/I:[NLH]\/A:[NLH](\/E:[XUPFH])?(\/RL:[XOTWU])?(\/RC:[XURC])?(\/CR:[XLMH])?(\/IR:[XLMH])?(\/AR:[XLMH])?(\/MAV:[XNALP])?(\/MAC:[XLH])?(\/MPR:[XNLH])?(\/MUI:[XNR])?(\/MS:[XUC])?(\/MC:[XNLH])?(\/MI:[XNLH])?(\/MA:[XNLH])?$/;
405
+
406
+ function parseCvss31Vector(v) {
407
+ if (typeof v !== 'string' || v.length === 0) {
408
+ return { ok: false, version: null, reason: 'cvss_vector is not a non-empty string' };
409
+ }
410
+ const versionMatch = v.match(/^CVSS:(\d+\.\d+)\//);
411
+ if (!versionMatch) {
412
+ return { ok: false, version: null, reason: 'cvss_vector does not start with a CVSS:X.Y/ version prefix' };
413
+ }
414
+ const version = versionMatch[1];
415
+ if (version !== '3.0' && version !== '3.1') {
416
+ return { ok: false, version, reason: `cvss_vector declares version ${version}; CSAF 2.0 cvss_v3 accepts 3.0 and 3.1 only. Backfill a CVSS 3.x vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support).` };
417
+ }
418
+ if (!CVSS_3X_RE.test(v)) {
419
+ return { ok: false, version, reason: 'cvss_vector does not match the strict CVSS 3.x grammar (missing/invalid mandatory metric, unknown metric value, or out-of-order metric)' };
420
+ }
421
+ return { ok: true, version };
422
+ }
423
+
383
424
  module.exports = {
384
425
  score,
385
426
  scoreCustom,
@@ -388,6 +429,7 @@ module.exports = {
388
429
  validate,
389
430
  validateFactors,
390
431
  deriveRwepFromFactors,
432
+ parseCvss31Vector,
391
433
  RWEP_WEIGHTS,
392
434
  ACTIVE_EXPLOITATION_LADDER,
393
435
  RECOGNISED_FACTOR_KEYS,
package/lib/sign.js CHANGED
@@ -178,9 +178,9 @@ function signAll() {
178
178
 
179
179
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
180
180
 
181
- // S5: verdict line FIRST, fingerprint banner after. An operator
182
- // scrolling output should not be able to see "fingerprint: SHA256..."
183
- // and assume success when errors > 0.
181
+ // Verdict line FIRST, fingerprint banner after. An operator scrolling
182
+ // output should not be able to see "fingerprint: SHA256..." and assume
183
+ // success when errors > 0.
184
184
  if (errors > 0) {
185
185
  console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
186
186
  } else {
@@ -343,14 +343,13 @@ function canonicalManifestBytes(manifest) {
343
343
  * Returns the manifest_signature object literal to splice into the
344
344
  * manifest top level.
345
345
  *
346
- * A: the previous shape included a `signed_at` ISO timestamp.
347
- * That field was stripped from the canonical bytes before signing (via
348
- * `delete clone.manifest_signature`), so it was NOT covered by the
349
- * signature — an attacker who replayed a known-valid signature could
350
- * rewrite `signed_at` to any value, lending false freshness authority to
351
- * a stale signature. The field is now omitted entirely. Freshness signal
352
- * lives outside the signed bytes (git-log mtime of manifest.json, npm
353
- * publish timestamp).
346
+ * The manifest_signature shape carries `algorithm` + `signature_base64`
347
+ * only no `signed_at` ISO timestamp. A `signed_at` field stripped from
348
+ * the canonical bytes before signing would be unsigned metadata; an
349
+ * attacker who replayed a known-valid signature could rewrite it to any
350
+ * value, lending false freshness authority to a stale signature.
351
+ * Freshness signal lives outside the signed bytes (git-log mtime of
352
+ * manifest.json, npm publish timestamp).
354
353
  *
355
354
  * @param {object} manifest
356
355
  * @param {string} privateKey PEM-encoded Ed25519 private key
package/lib/source-osv.js CHANGED
@@ -175,7 +175,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
175
175
  return new Promise((resolve, reject) => {
176
176
  const t = osvTransport();
177
177
  if (t.error) {
178
- // F13: surface the validation error structurally; no retry.
178
+ // Surface the validation error structurally; no retry.
179
179
  return resolve({ ok: false, error: t.error, source: "offline" });
180
180
  }
181
181
  const { mod, host, port } = t;
@@ -539,7 +539,7 @@ function extractCvss(rec) {
539
539
  const prev = vectorsByVersion.get(ver);
540
540
  if (!prev) vectorsByVersion.set(ver, v);
541
541
  }
542
- // F10: try versions in descending order. CVSS 4.0 derivation is not yet
542
+ // Try versions in descending order. CVSS 4.0 derivation is not yet
543
543
  // implemented here — if v4 was the highest but can't be computed, walk
544
544
  // down to v3.x. Only return null when ALL versions fail.
545
545
  const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
@@ -737,7 +737,7 @@ function normalizeAdvisory(rec) {
737
737
  // OSV.dev canonical advisory URL — used as the primary vendor advisory.
738
738
  const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
739
739
 
740
- // F6: dedupe verification_sources. OSV records frequently carry the
740
+ // Dedupe verification_sources. OSV records frequently carry the
741
741
  // canonical osv.dev URL in references[] as well, which would otherwise
742
742
  // produce a duplicate alongside the prepended `osvUrl`.
743
743
  const verification_sources = Array.from(new Set([
@@ -746,9 +746,9 @@ function normalizeAdvisory(rec) {
746
746
  ...refUrls.slice(0, 10),
747
747
  ]));
748
748
 
749
- // F5: EPSS coverage does not extend to non-CVE identifiers. Surface this
749
+ // EPSS coverage does not extend to non-CVE identifiers. Surface this
750
750
  // explicitly so curators know to re-query if MITRE later assigns a CVE
751
- // id to the entry. Wording mirrors the MAL-2026-3083 catalog entry.
751
+ // id to the entry.
752
752
  const isCveKey = /^CVE-/i.test(catalogKey);
753
753
  const epss_note = isCveKey
754
754
  ? null
@@ -840,13 +840,13 @@ async function buildDiff(ctx) {
840
840
  const cveCatalog = ctx.cveCatalog || {};
841
841
  const existingKeys = new Set(Object.keys(cveCatalog));
842
842
  const diffs = [];
843
- // F7: distinguish unreachable (fetch failed, network or 5xx) from
843
+ // Distinguish unreachable (fetch failed, network or 5xx) from
844
844
  // normalize-rejected (record fetched but normalization produced null).
845
845
  // Operators triaging a refresh-report want to know whether to chase a
846
846
  // network outage or a malformed upstream record.
847
847
  let unreachable = 0;
848
848
  let normalizeErrors = 0;
849
- // Finding 18: ids that ARE in the catalog but skipped because of overlap
849
+ // Ids that ARE in the catalog but skipped because of overlap
850
850
  // are not "errors"; surface them so the summary doesn't read as silently
851
851
  // dropping work. Particularly useful when a curator dispatches the same
852
852
  // batch twice and wonders why nothing happened.
@@ -28,12 +28,13 @@ const ROOT = path.resolve(__dirname, "..");
28
28
  const { fetchLatestPublished, buildFreshnessReport } = require("./upstream-check.js");
29
29
 
30
30
  function parseArgs(argv) {
31
- const out = { timeoutMs: 5000, raw: false };
31
+ const out = { timeoutMs: 5000, raw: false, airGap: false };
32
32
  for (let i = 2; i < argv.length; i++) {
33
33
  const a = argv[i];
34
34
  if (a === "--timeout") { out.timeoutMs = parseInt(argv[++i], 10) || 5000; }
35
35
  else if (a.startsWith("--timeout=")) { out.timeoutMs = parseInt(a.slice("--timeout=".length), 10) || 5000; }
36
36
  else if (a === "--raw") out.raw = true;
37
+ else if (a === "--air-gap") out.airGap = true;
37
38
  }
38
39
  return out;
39
40
  }
@@ -52,6 +53,20 @@ function readManifest() {
52
53
 
53
54
  (async () => {
54
55
  const opts = parseArgs(process.argv);
56
+ // Air-gap short-circuit — the registry probe is a network operation. When
57
+ // the operator has declared air-gapped mode (env var OR --air-gap), emit a
58
+ // structured `skipped` envelope and exit 0. Exit 0 because, like the
59
+ // existing "offline" path, missing freshness data is a graceful degradation
60
+ // — it is NOT an error condition for downstream callers.
61
+ if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) {
62
+ process.stdout.write(JSON.stringify({
63
+ ok: null,
64
+ skipped: "air-gap",
65
+ reason: "registry probe disabled in air-gap mode",
66
+ source: "upstream-check",
67
+ }) + "\n");
68
+ process.exit(0);
69
+ }
55
70
  const registry = await fetchLatestPublished({ timeoutMs: opts.timeoutMs });
56
71
  if (opts.raw) {
57
72
  process.stdout.write(JSON.stringify(registry) + "\n");
@@ -33,6 +33,15 @@ const REQUEST_TIMEOUT_MS = 5000;
33
33
  * a path to a JSON file with { version, time: { <ver>: ISO } } shape.
34
34
  */
35
35
  async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName = PKG_NAME } = {}) {
36
+ // Air-gap refusal — registry probes are a network operation and must never
37
+ // be issued when the operator has declared an air-gapped environment.
38
+ // Returning a structured refusal (instead of throwing) lets callers degrade
39
+ // gracefully the same way they handle `offline` — the freshness signal is
40
+ // intentionally absent, not in error.
41
+ if (process.env.EXCEPTD_AIR_GAP === "1") {
42
+ return { ok: false, error: "air-gap-blocked", source: "fetchLatestPublished" };
43
+ }
44
+
36
45
  if (process.env.EXCEPTD_REGISTRY_FIXTURE) {
37
46
  try {
38
47
  const fs = require("fs");
@@ -242,7 +242,7 @@ function main() {
242
242
  console.log(
243
243
  `\n${passed}/${total} catalogs validated${warnSuffix}${failSuffix}.`,
244
244
  );
245
- // F18: process.exitCode + return so buffered writes drain.
245
+ // process.exitCode + return so buffered writes drain.
246
246
  process.exitCode = failed === 0 ? 0 : 1;
247
247
  }
248
248
 
@@ -418,7 +418,7 @@ function main() {
418
418
  (warned ? `, ${warned} with warnings` : '') +
419
419
  (failed ? `, ${failed} failed` : '') + '.';
420
420
  console.log(summary);
421
- // F18: process.exitCode + return so buffered output drains.
421
+ // process.exitCode + return so buffered output drains.
422
422
  process.exitCode = failed === 0 ? 0 : 1;
423
423
  }
424
424
 
package/lib/verify.js CHANGED
@@ -164,14 +164,14 @@ function signAll() {
164
164
  const manifestSig = crypto.sign(null, canonical, {
165
165
  key: privateKey, dsaEncoding: 'ieee-p1363',
166
166
  });
167
- // A: `signed_at` is intentionally OMITTED. The previous shape
168
- // emitted a `signed_at` timestamp alongside the Ed25519 signature, but
169
- // `signed_at` was stripped from the canonical bytes before signing so
170
- // an attacker could replay a known-valid signature against the same
171
- // canonical content while rewriting `signed_at` to any value, lending
172
- // false freshness authority to a stale signature. Operators who need
173
- // freshness signal should consult the git-log mtime of manifest.json
174
- // (or the npm publish timestamp), which are external to the signed bytes.
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.
175
175
  manifest.manifest_signature = {
176
176
  algorithm: 'Ed25519',
177
177
  signature_base64: manifestSig.toString('base64'),
@@ -347,11 +347,12 @@ function verifyManifestSignature(manifest) {
347
347
  if (typeof sig.signature_base64 !== 'string') {
348
348
  return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
349
349
  }
350
- // E: require the algorithm field to be present and exactly
351
- // 'Ed25519'. The previous form accepted a missing algorithm field
352
- // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) which let a
353
- // future downgrade attacker drop the field to bait a weaker default.
354
- // lib/sign.js always writes the field, so no legitimate consumer breaks.
350
+ // Require the algorithm field to be present and exactly 'Ed25519'.
351
+ // Accepting a missing algorithm field
352
+ // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) would let a
353
+ // downgrade attacker drop the field to bait a weaker default.
354
+ // lib/sign.js always writes the field, so no legitimate consumer
355
+ // breaks.
355
356
  if (sig.algorithm !== 'Ed25519') {
356
357
  return {
357
358
  status: 'invalid',
@@ -441,10 +442,10 @@ function loadManifestValidated() {
441
442
  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.`);
442
443
  }
443
444
  if (sigResult.status === 'missing') {
444
- // D: dedupe the legacy-tarball warning. Many CLI verbs
445
- // call loadManifestValidated() more than once per invocation; the
446
- // previous console.warn spammed stderr per call. Node's emitWarning()
447
- // with a stable `code` collapses repeated emissions automatically.
445
+ // Dedupe the legacy-tarball warning. Many CLI verbs call
446
+ // loadManifestValidated() more than once per invocation, so a plain
447
+ // console.warn would spam stderr per call. Node's emitWarning() with
448
+ // a stable `code` collapses repeated emissions automatically.
448
449
  process.emitWarning(
449
450
  '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 `node lib/sign.js sign-all` to add the signature.',
450
451
  { code: 'EXCEPTD_MANIFEST_UNSIGNED' }
@@ -594,12 +595,12 @@ function validateAgainstSchema(value, schema, here, root) {
594
595
  * @param {string} [pinPath] optional override (testability)
595
596
  */
596
597
  /**
597
- * KK P1-5: shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file,
598
- * strips a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would
599
- * otherwise prepend U+FEFF and silently break every verify path on the host),
600
- * tolerates CRLF line endings, ignores comment lines (`#`) and blanks, and
601
- * returns the first non-comment / non-empty line. Returns null if the file
602
- * is unreadable / empty.
598
+ * Shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file, strips
599
+ * a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would otherwise
600
+ * prepend U+FEFF and silently break every verify path on the host),
601
+ * tolerates CRLF line endings, ignores comment lines (`#`) and blanks,
602
+ * and returns the first non-comment / non-empty line. Returns null if
603
+ * the file is unreadable / empty.
603
604
  *
604
605
  * Shared across four sites so every loader normalises identically:
605
606
  * - lib/verify.js (manifest signature gate)
@@ -610,9 +611,26 @@ function validateAgainstSchema(value, schema, here, root) {
610
611
  * four sites under a BOM + CRLF fuzz corpus.
611
612
  */
612
613
  function loadExpectedFingerprintFirstLine(pinPath) {
613
- let raw;
614
- try { raw = fs.readFileSync(pinPath, 'utf8'); }
614
+ let buf;
615
+ try { buf = fs.readFileSync(pinPath); }
615
616
  catch { return null; }
617
+ if (buf.length >= 2) {
618
+ const b0 = buf[0];
619
+ const b1 = buf[1];
620
+ // UTF-16LE (FF FE) and UTF-16BE (FE FF) pin files would silently decode
621
+ // as UTF-8 mojibake — the first line never matches a live fingerprint
622
+ // and the operator sees no signal. Refuse them; re-save as UTF-8.
623
+ if ((b0 === 0xFF && b1 === 0xFE) || (b0 === 0xFE && b1 === 0xFF)) return null;
624
+ // UTF-16BE-without-BOM defense: a pin file saved as UTF-16BE on a host
625
+ // whose editor stripped the BOM (or never wrote one) decodes as
626
+ // 0x00 <printable ASCII> 0x00 <printable ASCII>... The leading NUL byte
627
+ // would survive into the utf8 string and the first-line compare would
628
+ // never match a SHA256:... fingerprint. Detect "00 XX" where XX is
629
+ // printable ASCII (0x20-0x7E) and refuse — same remediation: re-save
630
+ // the file as UTF-8.
631
+ if (b0 === 0x00 && b1 >= 0x20 && b1 <= 0x7E) return null;
632
+ }
633
+ let raw = buf.toString('utf8');
616
634
  if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
617
635
  const lines = raw
618
636
  .split(/\r?\n/)
@@ -627,7 +645,7 @@ function checkExpectedFingerprint(liveFp, pinPath) {
627
645
  if (!liveFp || typeof liveFp.sha256 !== 'string') {
628
646
  return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
629
647
  }
630
- // KK P1-5: route through the shared loader so a BOM-prefixed pin file
648
+ // Route through the shared loader so a BOM-prefixed pin file
631
649
  // (Notepad with files.encoding=utf8bom) is tolerated identically across
632
650
  // every verify site. Pre-fix the verbatim split-trim-find produced a
633
651
  // first-line of "SHA256:..." (with leading BOM) that would never equal
@@ -737,10 +755,18 @@ if (require.main === module) {
737
755
  );
738
756
  } else if (pinResult.status === 'mismatch') {
739
757
  if (pinResult.rotationOverride) {
740
- console.warn(
741
- `[verify] WARN: live key fingerprint ${pinResult.actual} differs from pin ` +
742
- `${pinResult.expected}. KEYS_ROTATED=1 set accepting rotation. ` +
743
- `Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
758
+ process.emitWarning(
759
+ `live key fingerprint ${pinResult.actual} differs from pin ${pinResult.expected}; ` +
760
+ `KEYS_ROTATED=1 accepted. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
761
+ { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
762
+ );
763
+ // Mirror to stderr unconditionally: NODE_NO_WARNINGS=1 silences
764
+ // process.emitWarning, but a key-rotation override is a
765
+ // security-relevant event that must surface in the operator's
766
+ // terminal even when warnings are muted.
767
+ console.error(
768
+ `[verify] KEYS_ROTATED=1 override accepted; live fingerprint ${pinResult.actual} ` +
769
+ `differs from pin ${pinResult.expected}. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
744
770
  );
745
771
  } else {
746
772
  console.error(