@blamejs/exceptd-skills 0.12.21 → 0.12.22

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.
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-14T23:57:30.181Z",
3
+ "generated_at": "2026-05-15T06:49:36.722Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 50,
6
6
  "source_hashes": {
7
- "manifest.json": "4b198eb657d469f7e3662489fdbb1de0938de8436064413c981ebbd442555f12",
7
+ "manifest.json": "2adf0e88322f3903ffd323dbcee69b406c7d801a1539cb1e79e569e47bbafc4c",
8
8
  "data/atlas-ttps.json": "20339e0ae3cd89c06f1385be31c50f408f827edc2e8ab8aef026ade3bcf0a917",
9
9
  "data/attack-techniques.json": "6db08a8e8a4d03d9309b1d185112de7f3c9595d2cd3d24566b7ce0b3b8aa5d1a",
10
10
  "data/cve-catalog.json": "7936ba3c8f27156235bf327830e8f1a684658865e97f089aed98b2a7cdbb88ef",
@@ -25,7 +25,7 @@
25
25
  "skills/rag-pipeline-security/skill.md": "78f00a39e66f08da2894e28eeedb32137295ca019eba7110ab28282d613a97eb",
26
26
  "skills/ai-c2-detection/skill.md": "095cab9daa072bfabc87152aea1b61ccd6da8f531753b05c181629f04014b5ca",
27
27
  "skills/policy-exception-gen/skill.md": "79db45ba722a6dd9bba25bf84e0b52cf659b56b662193cef80a8273337e41df9",
28
- "skills/threat-model-currency/skill.md": "d2b065610ed2f8cdece4f19a95b4e2122c150fc924b7b45fbed17f32a75f3a08",
28
+ "skills/threat-model-currency/skill.md": "4badf98c3d8ca5f5c9854cbb25ed994491c39a88ff1aa354b72c50e59a7d3261",
29
29
  "skills/global-grc/skill.md": "e0487de49679172347653d8c191d1f269193de6f444f6b0c6396d326e45bd72e",
30
30
  "skills/zeroday-gap-learn/skill.md": "086df0fe792c80ca864da6917958bf3df7be5ba02df1c5894972a69353306ee6",
31
31
  "skills/pqc-first/skill.md": "a5eb776e1ea3bb422a4c18a3bdf39ad2ec1651b3c25e65c89428ba319141b275",
@@ -70,6 +70,8 @@
70
70
  ],
71
71
  "cve_refs": [
72
72
  "CVE-2026-31431",
73
+ "CVE-2026-43284",
74
+ "CVE-2026-43500",
73
75
  "CVE-2026-46300"
74
76
  ],
75
77
  "cwe_refs": [
@@ -287,6 +287,15 @@ function lockFilePath(playbookId) {
287
287
  catch { return null; }
288
288
  }
289
289
 
290
+ // PP P1-1: same-PID stale-lockfile reclaim threshold. A same-process orphan
291
+ // (e.g. an earlier run() that crashed without unlinking, or a try/catch that
292
+ // swallowed the release) older than this is presumed dead and reclaimed.
293
+ // 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough that
294
+ // no legitimate playbook hold reaches it (govern/look/run phases complete
295
+ // well inside one second per playbook), short enough that a wedged process
296
+ // recovers within one CI step rather than the rest of its lifetime.
297
+ const STALE_LOCK_MS = 30_000;
298
+
290
299
  function acquireLock(playbookId) {
291
300
  const p = lockFilePath(playbookId);
292
301
  if (!p) return null;
@@ -322,6 +331,24 @@ function acquireLock(playbookId) {
322
331
  try { fs.unlinkSync(p); } catch {}
323
332
  try { writePayload(); return p; } catch { /* fall through */ }
324
333
  }
334
+ // PP P1-1: same-PID stale-lockfile reclaim. If the recorded pid is
335
+ // ours, the only way to escape an orphaned same-process lockfile is
336
+ // by mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
337
+ // (e.g. nested run() within one process) must still return null so
338
+ // the caller knows the lock is held. A fresh same-PID lockfile is
339
+ // reentrancy; one older than STALE_LOCK_MS is an orphan from a
340
+ // crashed prior hold (or a try/catch that swallowed the release)
341
+ // and must be reclaimed — otherwise the process can never acquire
342
+ // this lock again for the rest of its lifetime.
343
+ if (Number.isInteger(pid) && pid === process.pid) {
344
+ try {
345
+ const stat = fs.statSync(p);
346
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
347
+ try { fs.unlinkSync(p); } catch {}
348
+ try { writePayload(); return p; } catch { /* fall through */ }
349
+ }
350
+ } catch { /* stat failed — treat as held */ }
351
+ }
325
352
  } catch { /* unreadable lockfile — treat as held by a live process */ }
326
353
  }
327
354
  // Lock genuinely held (or filesystem error). Returning null keeps
@@ -367,6 +394,26 @@ function acquireLockDiagnostic(playbookId) {
367
394
  return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
368
395
  }
369
396
  }
397
+ // PP P1-1: same-PID stale-lockfile reclaim (diagnostic variant). Same
398
+ // semantics as in acquireLock: a same-process lockfile older than
399
+ // STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one is
400
+ // legitimate reentrancy and stays held.
401
+ if (Number.isInteger(pid) && pid === process.pid) {
402
+ let mtimeMs = null;
403
+ try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
404
+ if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
405
+ try { fs.unlinkSync(p); } catch {}
406
+ try {
407
+ fs.writeFileSync(p,
408
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
409
+ { flag: 'wx' });
410
+ return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
411
+ } catch (e3) {
412
+ return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
413
+ }
414
+ }
415
+ return { ok: false, reason: 'held_by_self', lock_path: p, holder_pid: pid };
416
+ }
370
417
  return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
371
418
  }
372
419
  return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
@@ -1534,7 +1581,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1534
1581
  return builtFormats.get(format);
1535
1582
  };
1536
1583
  const primaryBody = buildOnce(primaryFormat);
1537
- // audit CC P2-1: bundles_by_format must always be an object keyed by the
1584
+ // bundles_by_format must always be an object keyed by the
1538
1585
  // primary format, even when no extra formats were requested. Pre-fix it
1539
1586
  // was null in the single-format case, forcing downstream tooling into a
1540
1587
  // `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
@@ -1779,16 +1826,45 @@ function getEngineVersion() {
1779
1826
  return _CACHED_PKG_VERSION;
1780
1827
  }
1781
1828
 
1782
- // audit CC P1-3 / P1-4: operator-supplied identity strings (--operator) and
1829
+ // 3 / P1-4: operator-supplied identity strings (--operator) and
1783
1830
  // publisher namespace URLs (--publisher-namespace) flow into operator-facing
1784
1831
  // CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass —
1785
1832
  // bin/exceptd.js already validates the inputs, but the runner is also called
1786
1833
  // from library consumers that may bypass the CLI surface.
1834
+ //
1835
+ // MM P1-D: extend the strip to Unicode bidi / format / control / surrogate /
1836
+ // private-use / unassigned categories (\p{C} under the `u` regex flag) so
1837
+ // direct library callers of buildEvidenceBundle cannot smuggle a U+202E
1838
+ // "RTL OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
1839
+ // already refuses (--operator validation in bin/exceptd.js). NFC-normalise
1840
+ // first so a decomposed sequence can't combine past the codepoint check;
1841
+ // cap the result at 256 codepoints (NOT UTF-16 code units) so a string of
1842
+ // astral-plane codepoints can't smuggle a longer-than-256-display string
1843
+ // past the cap by exploiting JavaScript's surrogate-pair string length.
1844
+ // Returns null on rejection (empty after strip, or NFC normalise threw);
1845
+ // callers (the publisher-namespace + contact_details + tracking.generator
1846
+ // sites) treat null as "operator-unclaimed" and route through the existing
1847
+ // fallback (publisher.namespace = urn:exceptd:operator:unknown +
1848
+ // bundle_publisher_unclaimed runtime warning).
1787
1849
  function sanitizeOperatorText(s) {
1788
1850
  if (typeof s !== 'string') return null;
1789
- // eslint-disable-next-line no-control-regex
1790
- const cleaned = s.replace(/[\x00-\x1F\x7F]/g, '').trim();
1791
- return cleaned.length ? cleaned.slice(0, 256) : null;
1851
+ // NFC first: a Cf codepoint may be expressed as a base + combining mark
1852
+ // that recomposes into the format category under NFC. Normalise so the
1853
+ // strip catches it.
1854
+ let normalised;
1855
+ try { normalised = s.normalize('NFC'); }
1856
+ catch { return null; }
1857
+ // Strip every Unicode codepoint matching General Category C
1858
+ // (Cc, Cf, Cs, Co, Cn). \p{C} under the `u` flag matches all five.
1859
+ const stripped = normalised.replace(/\p{C}/gu, '');
1860
+ const trimmed = stripped.trim();
1861
+ if (trimmed.length === 0) return null;
1862
+ // Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
1863
+ // units, so a 256-codepoint astral-plane string isn't silently extended
1864
+ // past the cap by surrogate-pair encoding).
1865
+ const cps = Array.from(trimmed);
1866
+ if (cps.length <= 256) return cps.join('');
1867
+ return cps.slice(0, 256).join('');
1792
1868
  }
1793
1869
 
1794
1870
  function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
@@ -1829,14 +1905,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1829
1905
  // that lied to downstream NVD / Red Hat dashboards. When
1830
1906
  // live_patch_available is the only signal, status stays known_affected
1831
1907
  // and the live-patch route is surfaced as a `vendor_fix` remediation.
1832
- // audit CC P1-2: CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1908
+ // CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1833
1909
  // regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
1834
1910
  // identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
1835
1911
  // validators (BSI CSAF validator, ENISA dashboard) refuse documents that
1836
1912
  // place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
1837
1913
  // to the `ids[]` array with a real `system_name`.
1838
1914
  //
1839
- // audit CC P2-2: CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
1915
+ // CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
1840
1916
  // cvss_v3 score block is emitted. Drop the entire score block when the
1841
1917
  // catalog has no CVSS data (score AND vector both unset); otherwise
1842
1918
  // include version + baseScore + vectorString + baseSeverity from the
@@ -1853,21 +1929,33 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1853
1929
  if (typeof vec !== 'string') return '3.1';
1854
1930
  const m = vec.match(/^CVSS:(\d+\.\d+)\//);
1855
1931
  if (!m) return '3.1';
1856
- // CSAF cvss_v3 block only accepts 3.x; if the catalog vector is 2.0 or
1857
- // 4.0 we still tag the block as the value the catalog declared. Strict
1858
- // validators that gate cvss_v3 to 3.0/3.1 will reject 2.0/4.0 but
1859
- // emitting the wrong version on a 4.0 vector would be worse.
1932
+ // Returns the declared version verbatim. The CALLER is responsible for
1933
+ // gating cvss_v3 emission to 3.0 / 3.1 per CSAF 2.0 schema. 2.0 and
1934
+ // 4.0 vectors are tagged here for diagnostic clarity but never reach
1935
+ // the cvss_v3 block downstream.
1860
1936
  return m[1];
1861
1937
  };
1862
1938
  const csafIdsFor = (id) => {
1863
- if (typeof id !== 'string' || !id) return { system_name: 'OSV', text: String(id) };
1864
- if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1865
- if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1866
- if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1867
- if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1868
- // Fallback: surface the raw value under a generic OSV system_name; any
1869
- // strict validator will at least know it's not a CVE.
1870
- return { system_name: 'OSV', text: id };
1939
+ // B: null / undefined / non-string id MUST NOT emit literal
1940
+ // "null" / "undefined" text into the vulnerabilities[] entry. Pre-fix
1941
+ // String(id) coerced both to those literals — strict validators then
1942
+ // rejected the document, and operators saw a phantom "null" CVE in
1943
+ // dashboards. Return null so the caller can skip the entry entirely
1944
+ // and surface a runtime_error for the missing id.
1945
+ if (typeof id !== 'string' || !id) return null;
1946
+ if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1947
+ if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1948
+ if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1949
+ if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1950
+ // A: RUSTSEC advisories carry their own tracking authority
1951
+ // (https://rustsec.org); mis-routing them to system_name 'OSV' loses
1952
+ // the upstream provenance link and confuses downstream ingesters that
1953
+ // resolve by (system_name, text) pair.
1954
+ if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
1955
+ // B: genuinely-unknown prefix surfaces as `exceptd-unknown`
1956
+ // so downstream ingesters know the authority wasn't recognized — pre-fix
1957
+ // every unknown id was misattributed to OSV.
1958
+ return { system_name: 'exceptd-unknown', text: id };
1871
1959
  };
1872
1960
  const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
1873
1961
 
@@ -1879,18 +1967,59 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1879
1967
  || (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
1880
1968
  product_ids: [productId],
1881
1969
  }];
1882
- // audit CC P2-2: only emit cvss_v3 score block when we have a real
1970
+ // B: catalog entries with a missing / non-string cve_id
1971
+ // pre-fix produced literal `text: "null"` / `text: "undefined"` entries
1972
+ // under ids[]. Skip the vulnerability entry entirely and surface a
1973
+ // runtime_error so the catalog gap is visible to operators / CI gates.
1974
+ const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
1975
+ let idEntry = null;
1976
+ if (!idIsCve) {
1977
+ idEntry = csafIdsFor(c.cve_id);
1978
+ if (idEntry == null) {
1979
+ if (Array.isArray(runOpts._runErrors)) {
1980
+ const alreadyMissing = runOpts._runErrors.some(e => e && e.kind === 'bundle_cve_id_missing');
1981
+ if (!alreadyMissing) {
1982
+ runOpts._runErrors.push({
1983
+ kind: 'bundle_cve_id_missing',
1984
+ reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
1985
+ remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
1986
+ });
1987
+ }
1988
+ }
1989
+ return null;
1990
+ }
1991
+ }
1992
+ // only emit cvss_v3 score block when we have a real
1883
1993
  // vector string AND a numeric score. Pre-fix every vuln carried
1884
1994
  // `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
1885
1995
  // signal — strict validators reject the truncated block, and
1886
1996
  // `base_score: 0` was a downstream-misleading default that suggested
1887
1997
  // an authoritative "informational" score where there was simply no
1888
1998
  // data.
1999
+ //
2000
+ // C: CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1.
2001
+ // Catalog vectors prefixed CVSS:2.0/ or CVSS:4.0/ would pre-fix emit a
2002
+ // cvss_v3 block with version: '2.0' / '4.0', which strict validators
2003
+ // (BSI CSAF Validator) reject outright. Drop the block for non-3.x
2004
+ // vectors and surface a runtime_error so operators can see why their
2005
+ // CVSS data didn't make it through.
1889
2006
  const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
1890
- const scores = hasCvss ? [{
2007
+ const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
2008
+ const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
2009
+ if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
2010
+ const alreadyUnsup = runOpts._runErrors.some(e => e && e.kind === 'bundle_cvss_v3_version_unsupported');
2011
+ if (!alreadyUnsup) {
2012
+ runOpts._runErrors.push({
2013
+ kind: 'bundle_cvss_v3_version_unsupported',
2014
+ reason: `Catalog entry carries CVSS vector with version ${vectorVersion}; CSAF 2.0 cvss_v3 block only accepts versions 3.0 / 3.1. The score block was omitted from this vulnerability to keep the document valid against strict CSAF validators.`,
2015
+ remediation: 'Backfill a CVSS 3.1 vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support) — exceptd targets CSAF 2.0 today.'
2016
+ });
2017
+ }
2018
+ }
2019
+ const scores = cvssV3Eligible ? [{
1891
2020
  products: [productId],
1892
2021
  cvss_v3: {
1893
- version: csafCvssVersionFromVector(c.cvss_vector),
2022
+ version: vectorVersion,
1894
2023
  baseScore: c.cvss_score,
1895
2024
  vectorString: c.cvss_vector,
1896
2025
  baseSeverity: csafCvssSeverity(c.cvss_score),
@@ -1902,12 +2031,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1902
2031
  remediations,
1903
2032
  product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
1904
2033
  };
1905
- // audit CC P1-2: route by id shape.
1906
- if (CSAF_CVE_RE.test(c.cve_id)) {
2034
+ // route by id shape.
2035
+ if (idIsCve) {
1907
2036
  return { cve: c.cve_id, ...base };
1908
2037
  }
1909
- return { ids: [csafIdsFor(c.cve_id)], ...base };
1910
- });
2038
+ return { ids: [idEntry], ...base };
2039
+ }).filter(v => v != null);
1911
2040
  const indicatorVulns = indicatorHits.map(i => ({
1912
2041
  // CSAF `system_name` values land in operator-facing validators; the
1913
2042
  // "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
@@ -1940,7 +2069,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1940
2069
  text: lines.join('\n'),
1941
2070
  };
1942
2071
  });
1943
- // audit CC P1-3: CSAF §3.1.7.4 publisher.namespace MUST be the trust
2072
+ // CSAF §3.1.7.4 publisher.namespace MUST be the trust
1944
2073
  // anchor of the entity publishing the advisory — the OPERATOR running the
1945
2074
  // scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
1946
2075
  // claimed https://exceptd.com as namespace, falsely attributing
@@ -1967,7 +2096,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1967
2096
  title: 'Publisher namespace not supplied',
1968
2097
  text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
1969
2098
  }] : [];
1970
- // audit CC P1-3: ALSO surface the unclaimed-publisher condition through
2099
+ // ALSO surface the unclaimed-publisher condition through
1971
2100
  // the structured runtime_errors[] accumulator so machine-readable
1972
2101
  // consumers (CI gates, dashboards) can branch on it without parsing
1973
2102
  // notes[] prose. The orchestrator's post-close pass folds late-pushed
@@ -1986,7 +2115,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1986
2115
  }
1987
2116
  }
1988
2117
 
1989
- // audit CC P1-4: thread the validated --operator name into
2118
+ // thread the validated --operator name into
1990
2119
  // tracking.generator (engine identity) AND publisher.contact_details
1991
2120
  // (operator-of-record). engine.version is read from the package once per
1992
2121
  // process. contact_details is omitted when no operator was supplied so
@@ -1998,7 +2127,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1998
2127
  };
1999
2128
  if (operatorClean) publisherBlock.contact_details = operatorClean;
2000
2129
 
2001
- // audit CC P1-1: CSAF §3.1.11.3.5.1 defines `final` as an immutable
2130
+ // CSAF §3.1.11.3.5.1 defines `final` as an immutable
2002
2131
  // advisory; subsequent re-emits against the same tracking.id are
2003
2132
  // refused by strict validators (BSI CSAF Validator). Runtime detection
2004
2133
  // runs with no operator review loop are inherently revisable, so the
@@ -2028,7 +2157,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2028
2157
  id: `exceptd-${playbook._meta.id}-${sessionId}`,
2029
2158
  status: csafStatus,
2030
2159
  version: playbook._meta.version,
2031
- // audit CC P1-4: name the engine that emitted the advisory.
2160
+ // name the engine that emitted the advisory.
2032
2161
  // CSAF §3.1.11.3.2 places this under tracking.generator.engine.
2033
2162
  generator: {
2034
2163
  engine: { name: 'exceptd', version: getEngineVersion() },
@@ -2066,7 +2195,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2066
2195
  // render empty fields.
2067
2196
  if (format === 'sarif' || format === 'sarif-2.1.0') {
2068
2197
  const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
2069
- // audit CC P2-6: SARIF rule ids are global within a single sarif-log run.
2198
+ // SARIF rule ids are global within a single sarif-log run.
2070
2199
  // Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
2071
2200
  // across playbooks) collided when results from multiple playbook runs
2072
2201
  // were merged into one SARIF document — GitHub Code Scanning de-dupes
@@ -2994,6 +3123,10 @@ module.exports = {
2994
3123
  vexFilterFromDoc,
2995
3124
  normalizeSubmission,
2996
3125
  autoDetectPreconditions,
3126
+ // MM P1-D: exposed for tests/audit-vv-trust-fixes.test.js so library-side
3127
+ // direct callers (the fallback path the CLI guard cannot reach) can be
3128
+ // exercised without spawning a CLI subprocess.
3129
+ sanitizeOperatorText,
2997
3130
  // internal helpers exposed for tests
2998
3131
  _resolvedPhase: resolvedPhase,
2999
3132
  _deepMerge: deepMerge,
@@ -191,7 +191,7 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
191
191
  } catch { return false; }
192
192
  }
193
193
 
194
- // v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
194
+ // v0.12.14: CRLF/BOM normalization mirrors lib/verify.js's
195
195
  // normalize(). Duplicated here to keep refresh-network free of cross-module
196
196
  // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
197
197
  // lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
@@ -342,7 +342,7 @@ async function main() {
342
342
  process.exitCode = 4; return;
343
343
  }
344
344
 
345
- // v0.12.14 (audit F6, F3): verify SHA-512 SRI first (collision-resistant
345
+ // v0.12.14: verify SHA-512 SRI first (collision-resistant
346
346
  // beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
347
347
  // signatures[] (npm registry's Ed25519 signing key). Each layer is
348
348
  // defense-in-depth — registry compromise that produces a SHA-1 collision
@@ -420,8 +420,13 @@ async function main() {
420
420
  const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
421
421
  if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
422
422
  try {
423
- const expectedFp = fs.readFileSync(expectedFingerprintPath, "utf8")
424
- .split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0);
423
+ // KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
424
+ // pin file (Notepad with files.encoding=utf8bom) is tolerated identically
425
+ // across every verify site. Pre-fix the inline split-trim-find returned
426
+ // the BOM as part of the first line, which would never match a live
427
+ // fingerprint and would block every legitimate refresh-network run.
428
+ const { loadExpectedFingerprintFirstLine } = require("./verify.js");
429
+ const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
425
430
  // v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
426
431
  // keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
427
432
  // `fingerprintPublicKey()` returns the raw base64 without the
@@ -244,8 +244,8 @@ function additionalChecks(key, entry, ctx) {
244
244
  }
245
245
 
246
246
  // V2 — Cross-catalog reference resolution. Unresolved refs are warnings
247
- // for v0.12.x; v0.13.0 will flip to hard failures. Audit D's V2 expansion
248
- // (Audit G) extends the walk from cwe_refs only to attack_refs, atlas_refs,
247
+ // for v0.12.x; v0.13.0 will flip to hard failures. V2 expansion
248
+ // extends the walk from cwe_refs only to attack_refs, atlas_refs,
249
249
  // d3fend_refs, AND framework_control_gaps.
250
250
  const REF_FIELDS = [
251
251
  { field: 'atlas_refs', set: ctx.atlasKeys, file: 'data/atlas-ttps.json' },
package/lib/verify.js CHANGED
@@ -362,7 +362,7 @@ function verifyManifestSignature(manifest) {
362
362
  if (!publicKey) {
363
363
  return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
364
364
  }
365
- // Audit AA P1-3: consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify so
365
+ // consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify so
366
366
  // library callers (refresh-network gate, verify-shipped-tarball gate, tests,
367
367
  // downstream consumers via `require("lib/verify")`) cannot bypass the pin.
368
368
  // Previously the pin only fired at the CLI tail of `node lib/verify.js`,
@@ -593,16 +593,46 @@ function validateAgainstSchema(value, schema, here, root) {
593
593
  * @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
594
594
  * @param {string} [pinPath] optional override (testability)
595
595
  */
596
+ /**
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.
603
+ *
604
+ * Shared across four sites so every loader normalises identically:
605
+ * - lib/verify.js (manifest signature gate)
606
+ * - lib/refresh-network.js (refresh-network pre-swap gate)
607
+ * - scripts/verify-shipped-tarball.js (predeploy gate)
608
+ * - bin/exceptd.js (attestation pin)
609
+ * tests/normalize-contract.test.js asserts byte-identical output across all
610
+ * four sites under a BOM + CRLF fuzz corpus.
611
+ */
612
+ function loadExpectedFingerprintFirstLine(pinPath) {
613
+ let raw;
614
+ try { raw = fs.readFileSync(pinPath, 'utf8'); }
615
+ catch { return null; }
616
+ if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
617
+ const lines = raw
618
+ .split(/\r?\n/)
619
+ .map((l) => l.trim())
620
+ .filter((l) => l.length > 0 && !l.startsWith('#'));
621
+ return lines[0] || null;
622
+ }
623
+
596
624
  function checkExpectedFingerprint(liveFp, pinPath) {
597
625
  const p = pinPath || EXPECTED_FINGERPRINT_PATH;
598
626
  if (!fs.existsSync(p)) return { status: 'no-pin' };
599
627
  if (!liveFp || typeof liveFp.sha256 !== 'string') {
600
628
  return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
601
629
  }
602
- const expected = fs.readFileSync(p, 'utf8').trim();
603
- // Tolerate trailing comment / whitespace on the same line; the file's
604
- // first non-empty line is the canonical fingerprint.
605
- const firstLine = expected.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || '';
630
+ // KK P1-5: route through the shared loader so a BOM-prefixed pin file
631
+ // (Notepad with files.encoding=utf8bom) is tolerated identically across
632
+ // every verify site. Pre-fix the verbatim split-trim-find produced a
633
+ // first-line of "SHA256:..." (with leading BOM) that would never equal
634
+ // a live fingerprint.
635
+ const firstLine = loadExpectedFingerprintFirstLine(p) || '';
606
636
  if (firstLine === liveFp.sha256) return { status: 'match' };
607
637
  return {
608
638
  status: 'mismatch',
@@ -740,6 +770,7 @@ module.exports = {
740
770
  validateAgainstSchema,
741
771
  publicKeyFingerprint,
742
772
  checkExpectedFingerprint,
773
+ loadExpectedFingerprintFirstLine,
743
774
  canonicalManifestBytes,
744
775
  verifyManifestSignature,
745
776
  EXPECTED_FINGERPRINT_PATH,