@blamejs/exceptd-skills 0.19.32 → 0.19.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -2,35 +2,10 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * lib/refresh-network.js
6
- *
7
- * `exceptd refresh --network` — fetch the latest signed catalog snapshot
8
- * from the maintainer's npm-published tarball, verify every skill
9
- * signature against the Ed25519 public key already shipped in the
10
- * operator's local install, and swap in the fresh `data/`, `manifest.json`,
11
- * and `manifest-snapshot.json`.
12
- *
13
- * Trust boundary:
14
- * - Trust anchor = the `keys/public.pem` already present in the local
15
- * install. Operators rotated the key by running `npm install -g` at
16
- * some earlier point; nothing in --network changes that root.
17
- * - Tarball authenticity = each shipped `skills/<name>/SKILL.md` (or
18
- * equivalent payload) has an Ed25519 signature in manifest.json that
19
- * resolves against the local public key. ANY signature mismatch aborts
20
- * the swap; the local install is untouched.
21
- *
22
- * Why this exists:
23
- * `npm update -g` already pulls the same signed artifact (npm provenance
24
- * + OIDC). --network is for operators who want only the data slice
25
- * without re-resolving CLI/lib code, OR who are on a constrained host
26
- * where the global npm install requires sudo and they're running a
27
- * user-local copy they can write to.
28
- *
29
- * Requires write access to the install directory. Fails fast with a
30
- * clear message + the `npm update` fallback when the install is not
31
- * writable (typical for system-global installs).
32
- *
33
- * Zero npm deps. Node 24 stdlib only.
5
+ * `exceptd refresh --network` — fetch the npm-published tarball, verify every
6
+ * skill signature against the local install's Ed25519 key, and swap in the
7
+ * fresh `data/`, `manifest.json` and `manifest-snapshot.json`. The local
8
+ * `keys/public.pem` is the trust anchor; --network never rotates it.
34
9
  */
35
10
 
36
11
  const fs = require("fs");
@@ -42,21 +17,12 @@ const os = require("os");
42
17
 
43
18
  const ROOT = path.resolve(__dirname, "..");
44
19
  const PKG_NAME = "@blamejs/exceptd-skills";
45
- // Unscoped basename for the npm tarball filename: @scope/name -> name.
46
20
  const PKG_UNSCOPED = PKG_NAME.includes("/") ? PKG_NAME.split("/").pop() : PKG_NAME;
47
- // Strict semver — the ONLY registry-metadata field embedded in the fetch URL.
48
- // A value that passes this guard cannot carry a path separator, URL scheme, or
49
- // host, so the canonical tarball URL built from it has no metadata-controlled
50
- // destination component.
21
+ // Strict semver — the only registry-metadata value embedded in the fetch URL.
51
22
  const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
52
23
 
53
- // The canonical npm tarball URL for this package@version, built entirely from
54
- // string literals plus a semver-guarded version. The fetch destination is no
55
- // longer derived from the metadata-supplied dist.tarball, so a tampered or
56
- // fixture-injected /latest response cannot steer the download at an internal or
57
- // attacker-controlled address — strictly stronger than host-allowlisting the
58
- // metadata URL. Content trust still rests on the SHA-512/SRI + shasum + Ed25519
59
- // checks downstream.
24
+ // Built from literals plus the guarded version, never from the metadata's
25
+ // dist.tarball, so a tampered /latest response cannot steer the download.
60
26
  function canonicalTarballUrl(version) {
61
27
  return `https://registry.npmjs.org/${PKG_NAME}/-/${PKG_UNSCOPED}-${version}.tgz`;
62
28
  }
@@ -83,16 +49,11 @@ function emit(obj, json) {
83
49
  }
84
50
 
85
51
  function progress(line, json) {
86
- // Progress messages always go to stderr so --json consumers see only the
87
- // final JSON result on stdout. Plain mode also routes progress through
88
- // stderr (so `exceptd refresh --network > catalog.log` doesn't mix logs
89
- // with structured output).
52
+ // Progress goes to stderr even in plain mode, so stdout carries only the result.
90
53
  process.stderr.write(`[refresh-network] ${line}\n`);
91
54
  }
92
55
 
93
- // Cap the registry metadata read so a hostile/misconfigured endpoint can't
94
- // stream unbounded JSON into RAM (the /latest response is a few KB; 16 MB is
95
- // generous headroom). Symmetric with getBuffer's tarball streaming cap.
56
+ // Caps the registry metadata read against an endpoint streaming unbounded JSON.
96
57
  const JSON_RESPONSE_CAP_BYTES = 16 * 1024 * 1024;
97
58
 
98
59
  function getJsonOnce(url, timeoutMs) {
@@ -129,34 +90,18 @@ function getJsonOnce(url, timeoutMs) {
129
90
  });
130
91
  }
131
92
 
132
- // Adopt the vendored blamejs retry primitive: a transient registry failure
133
- // (HTTP 429/5xx, ECONNRESET/ETIMEDOUT family) is retried with exponential
134
- // backoff + jitter instead of aborting the whole --network refresh on the
135
- // first network hiccup. Permanent conditions (parse error, size-cap) carry no
136
- // statusCode/code, so the classifier does not retry them.
93
+ // Transient failures (429/5xx, ECONNRESET/ETIMEDOUT) retry; permanent ones
94
+ // (parse error, size cap) carry no statusCode/code, so the classifier skips them.
137
95
  function getJson(url, timeoutMs) {
138
- // Lazy require: a parseTar-only consumer (scripts/verify-shipped-tarball.js)
139
- // loads this module in a context where vendor/ may not be present, and it
140
- // never calls getJson — so retry must not be a load-time dependency.
96
+ // Lazy: verify-shipped-tarball.js loads this module for parseTar where vendor/ may be absent.
141
97
  const { withRetry } = require("../vendor/blamejs/retry.js");
142
98
  return withRetry(() => getJsonOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
143
99
  }
144
100
 
145
- // The tarball URL is taken from the registry metadata (or, under
146
- // EXCEPTD_REGISTRY_FIXTURE, a local fixture file), so it is not a hardcoded
147
- // constant the way the /latest metadata URL is. Pin its host to the npm
148
- // registry the metadata was queried from, so a tampered metadata response or
149
- // fixture cannot steer the fetch at an internal or attacker-controlled address.
150
- // Content trust still rests on the SHA-512/SRI + shasum + Ed25519 checks below;
151
- // this is fetch-destination defense-in-depth.
152
101
  const ALLOWED_TARBALL_HOST = /(?:^|\.)npmjs\.org$|(?:^|\.)npmjs\.com$/;
153
102
 
154
- // Exported for in-process tests of the fetch-destination guard.
155
- // The guard parses the URL once and rejects any non-default port BEFORE the
156
- // hostname test, so validation and the subsequent https.get({host,path})
157
- // connect (which reuses u.host — port-inclusive) agree on the same value.
158
- // Without the port check a `registry.npmjs.org:9999` URL would pass the
159
- // hostname-only allowlist yet connect to the attacker-chosen port.
103
+ // Rejects a non-default port before the hostname test: `registry.npmjs.org:9999`
104
+ // passes a hostname-only allowlist yet connects to the attacker's port.
160
105
  function isAllowedTarballHost(url) {
161
106
  try {
162
107
  const u = new URL(url);
@@ -188,8 +133,6 @@ function getBufferOnce(url, timeoutMs) {
188
133
  }
189
134
  const chunks = [];
190
135
  let total = 0;
191
- // v0.12.14: enforce streaming size cap so a hostile
192
- // registry CDN can't stream gigabytes into RAM.
193
136
  res.on("data", (c) => {
194
137
  total += c.length;
195
138
  if (total > cap) {
@@ -205,39 +148,29 @@ function getBufferOnce(url, timeoutMs) {
205
148
  });
206
149
  }
207
150
 
208
- // Wrap the tarball fetch in the same vendored retry primitive (BJS-01). The
209
- // host-allowlist rejection + size-cap carry no statusCode/code, so a permanent
210
- // refusal is not retried; a transient 5xx / reset / timeout is.
151
+ // Same classifier as getJson: the host refusal and the size cap are permanent.
211
152
  function getBuffer(url, timeoutMs) {
212
153
  const { withRetry } = require("../vendor/blamejs/retry.js"); // lazy — see getJson
213
154
  return withRetry(() => getBufferOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
214
155
  }
215
156
 
216
157
  /**
217
- * Parse a tar buffer (uncompressed) and return [{ name, body }] entries.
218
- * Tiny implementation — supports the GNU ustar variant npm produces.
219
- * Skips PaxHeader entries; treats long-link entries (type L) by stitching
220
- * the next entry's name from the long-link body.
158
+ * Parse an uncompressed tar buffer into [{ name, body }] — the GNU ustar
159
+ * variant npm produces; a type-L entry supplies the following entry's name.
221
160
  */
222
161
  function parseTar(buf) {
223
162
  const entries = [];
224
163
  let offset = 0;
225
164
  let pendingLongName = null;
226
- // v0.12.12: tarballs from a compromised registry CDN could ship entries
227
- // with `..`-bearing names targeting paths outside the install root. The
228
- // immediate callers (verify-shipped-tarball.js + the network update path)
229
- // do hash + signature checks before honoring entries, so this is
230
- // defense-in-depth — drop the entry rather than handing a path-traversal
231
- // string downstream.
165
+ // An entry whose name escapes the extraction root is dropped.
232
166
  const isSafeName = (n) => {
233
167
  if (typeof n !== "string" || n.length === 0) return false;
234
- // Reject absolute paths AND any segment that is exactly ".."
235
168
  if (/^[\\/]/.test(n) || /^[A-Za-z]:[\\/]/.test(n)) return false;
236
169
  return !n.split(/[\\/]/).some((seg) => seg === "..");
237
170
  };
238
171
  while (offset + 512 <= buf.length) {
239
172
  const block = buf.subarray(offset, offset + 512);
240
- // empty block = end-of-archive marker
173
+ // An all-zero block is the end-of-archive marker.
241
174
  if (block.every((b) => b === 0)) break;
242
175
  let name = block.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
243
176
  const sizeStr = block.subarray(124, 136).toString("utf8").replace(/\0.*$|\s+$/g, "").trim();
@@ -255,7 +188,6 @@ function parseTar(buf) {
255
188
  entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
256
189
  }
257
190
  }
258
- // round up to 512
259
191
  offset = dataStart + Math.ceil(size / 512) * 512;
260
192
  }
261
193
  return entries;
@@ -275,13 +207,9 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
275
207
  } catch { return false; }
276
208
  }
277
209
 
278
- // Pinned npm registry signing keys (https://registry.npmjs.org/-/npm/v1/keys).
279
- // The registry signs `<pkg>@<version>:<integrity>` with an ECDSA P-256
280
- // (secp256r1 / ecdsa-sha2-nistp256) key; `dist.signatures[]` carries the
281
- // keyid + base64 signature. These keys are pinned in-tree on purpose: fetching
282
- // them over the network would mean authenticating the metadata response with a
283
- // key obtained from the same untrusted channel, which is circular. They also
284
- // rotate rarely and out-of-band. Values are SPKI DER, base64-encoded.
210
+ // Pinned npm registry signing keys (https://registry.npmjs.org/-/npm/v1/keys),
211
+ // as base64 SPKI DER. Pinned in-tree because fetching them over the channel
212
+ // they authenticate would be circular.
285
213
  const NPM_REGISTRY_KEYS = Object.freeze({
286
214
  "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA":
287
215
  "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==",
@@ -289,18 +217,8 @@ const NPM_REGISTRY_KEYS = Object.freeze({
289
217
  "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==",
290
218
  });
291
219
 
292
- // Verify the npm registry's signature over `<pkg>@<version>:<integrity>`.
293
- // Returns:
294
- // { status: "valid", keyid } — a pinned key verified the signature
295
- // { status: "absent" } — no dist.signatures entries supplied
296
- // { status: "unverifiable", reason } — integrity missing (nothing to sign over)
297
- // { status: "unknown-keyid", keyid } — signature keyid is not a pinned npm key
298
- // { status: "invalid", reason } — a present, pinned-keyid signature did NOT verify
299
- //
300
- // The caller treats `valid` as an authenticity anchor over the WHOLE tarball
301
- // (including the unsigned data/ tree), `invalid`/`unknown-keyid` as a hard
302
- // refusal, and `absent`/`unverifiable` as warn-and-continue (so private or
303
- // pre-signing registries still work — the SHA-512/SHA-1 gates still apply).
220
+ // Returns { status }: valid | absent | unverifiable | unknown-keyid | invalid.
221
+ // Callers refuse on invalid and unknown-keyid, and warn-and-continue otherwise.
304
222
  function verifyNpmRegistrySignature(pkg, version, integrity, signatures) {
305
223
  if (!Array.isArray(signatures) || signatures.length === 0) return { status: "absent" };
306
224
  if (typeof integrity !== "string" || integrity.length === 0) {
@@ -325,24 +243,17 @@ function verifyNpmRegistrySignature(pkg, version, integrity, signatures) {
325
243
  return { status: "invalid", reason: "no dist.signatures[] entry verified against the pinned npm registry key" };
326
244
  }
327
245
 
328
- // v0.12.14: CRLF/BOM normalization mirrors lib/verify.js's
329
- // normalize(). Duplicated here to keep refresh-network free of cross-module
330
- // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
331
- // lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
332
- // implementations form a byte-stability contract enforced by
333
- // tests/normalize-contract.test.js.
246
+ // CRLF/BOM normalization, duplicated to keep this module dependency-free. Any
247
+ // change must be mirrored in lib/verify.js, lib/sign.js and
248
+ // scripts/verify-shipped-tarball.js; tests/normalize-contract.test.js enforces it.
334
249
  function normalizeSkillBytes(buf) {
335
250
  let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
336
251
  if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
337
252
  return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
338
253
  }
339
254
 
340
- // B + Q P1: in-line manifest-signature verifier. Kept here
341
- // rather than imported from lib/verify.js so refresh-network.js retains
342
- // its no-cross-module-dep posture (mirrors the per-skill verify path).
343
- // ANY change to canonical-bytes computation here MUST stay in lockstep
344
- // with lib/sign.js canonicalManifestBytes() / lib/verify.js
345
- // canonicalManifestBytes() — tests/normalize-contract.test.js enforces.
255
+ // Canonical-bytes computation must stay in lockstep with
256
+ // canonicalManifestBytes() in lib/sign.js and lib/verify.js.
346
257
  function canonicalizeForRefresh(value) {
347
258
  if (Array.isArray(value)) return value.map(canonicalizeForRefresh);
348
259
  if (value && typeof value === "object") {
@@ -385,7 +296,7 @@ function verifyTarballManifestSignature(manifest, publicKeyPem) {
385
296
  return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 manifest signature did not verify against local public.pem" };
386
297
  }
387
298
 
388
- // Manifest path validation. Mirrors lib/verify.js validateSkillPath().
299
+ // Mirrors lib/verify.js validateSkillPath().
389
300
  function validateManifestSkillPath(skillPath) {
390
301
  if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
391
302
  if (skillPath.includes("\\")) throw new Error(`manifest skill.path must use forward slashes: ${JSON.stringify(skillPath)}`);
@@ -394,10 +305,7 @@ function validateManifestSkillPath(skillPath) {
394
305
  return skillPath;
395
306
  }
396
307
 
397
- // v0.12.14: tarball download size cap. A hostile registry CDN
398
- // could stream gigabytes; Node buffers chunks in RAM until OOM. Current
399
- // tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
400
- // EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
308
+ // Buffered-tarball cap; EXCEPTD_TARBALL_SIZE_CAP_BYTES overrides it.
401
309
  const TARBALL_SIZE_CAP_BYTES_DEFAULT = 200 * 1024 * 1024;
402
310
  function tarballSizeCap() {
403
311
  const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
@@ -409,15 +317,9 @@ async function main() {
409
317
  const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
410
318
  const localVersion = localPkg.version;
411
319
 
412
- // Air-gap refusal. --network needs egress to registry.npmjs.org for the
413
- // /latest metadata + tarball pull. Under air-gap there is no offline
414
- // substitute, so refuse before any network attempt and point at the
415
- // offline workflow. The refusal is UNCONDITIONAL w.r.t.
416
- // EXCEPTD_REGISTRY_FIXTURE: a present metadata fixture only stubs the
417
- // /latest read, not the subsequent tarball fetch (getBuffer still hits
418
- // canonicalUrl), so honoring the fixture under air-gap would let egress
419
- // happen anyway while silently disabling an operator-facing control.
420
- // Offline tests exercise the metadata+tarball path WITHOUT --air-gap.
320
+ // Refused before any network attempt, and unconditional with respect to
321
+ // EXCEPTD_REGISTRY_FIXTURE — the fixture stubs only the /latest read, so
322
+ // honoring it here would let the tarball fetch reach the network anyway.
421
323
  if (opts.airGap) {
422
324
  emit({
423
325
  ok: false,
@@ -432,11 +334,8 @@ async function main() {
432
334
  let meta;
433
335
  try {
434
336
  if (process.env.EXCEPTD_REGISTRY_FIXTURE) {
435
- // Honor the same fixture mechanism as upstream-check so test runners
436
- // can exercise the offline / reachable / unreachable branches without
437
- // touching the network. Fixture shape: a JSON file matching the
438
- // /<pkg>/latest registry response (must include version + dist.tarball
439
- // + dist.shasum, or just version to exercise the early-return path).
337
+ // Fixture shape mirrors upstream-check's /<pkg>/latest response: version +
338
+ // dist.tarball + dist.shasum, or version alone for the already-at-latest path.
440
339
  meta = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
441
340
  } else {
442
341
  meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace(/%40/g, "@").replace(/%2F/g, "/")}/latest`, opts.timeoutMs);
@@ -461,9 +360,7 @@ async function main() {
461
360
  return;
462
361
  }
463
362
 
464
- // Writable-install check. Global installs typically live in a
465
- // root-owned dir; refusing to fail-then-leave-partial is the safer
466
- // contract.
363
+ // Probe writability before anything is fetched — failing here beats failing mid-swap.
467
364
  const writeProbe = path.join(ROOT, `.write-probe-${process.pid}`);
468
365
  try {
469
366
  fs.writeFileSync(writeProbe, "");
@@ -477,13 +374,9 @@ async function main() {
477
374
  process.exitCode = 3; return;
478
375
  }
479
376
 
480
- // Build the fetch destination from constants + a semver-validated version
481
- // rather than from the metadata-supplied dist.tarball, so the registry
482
- // response cannot steer the download's host or path. The metadata URL is
483
- // still cross-checked for visibility; a divergence is noted, not fatal,
484
- // because the authenticity anchor is the SHA-512/shasum/Ed25519 chain below.
485
- // Guard the SAME binding that the URL is built from (`safeVersion`), so the
486
- // semver check is the only path by which a metadata value reaches the fetch.
377
+ // Guard the same binding the URL is built from, so the semver check is the only
378
+ // path a metadata value reaches the fetch. A dist.tarball divergence is noted,
379
+ // not fatal — authenticity rests on the hash and signature chain below.
487
380
  const safeVersion = typeof latestVersion === "string" ? latestVersion : "";
488
381
  if (!SEMVER_RE.test(safeVersion)) {
489
382
  emit({ ok: false, error: `registry metadata version is not valid semver: ${safeVersion.slice(0, 64)}` }, opts.json);
@@ -504,32 +397,15 @@ async function main() {
504
397
  process.exitCode = 2; return;
505
398
  }
506
399
 
507
- // v0.12.14: defense-in-depth tarball size cap.
508
400
  const sizeCap = tarballSizeCap();
509
401
  if (tgzBuf.length > sizeCap) {
510
402
  emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
511
403
  process.exitCode = 4; return;
512
404
  }
513
405
 
514
- // Verify SHA-512 SRI first (collision-resistant beyond SHA-1 reach), then
515
- // SHA-1 shasum for compatibility. Both are transport hashes drawn from the
516
- // registry metadata response, so they only prove the downloaded tarball
517
- // matches the hashes THAT RESPONSE declared — an attacker who can forge the
518
- // metadata serves a tampered tarball plus matching SHA-512/SHA-1 and both
519
- // pass. The npm registry's signature over `<pkg>@<version>:<integrity>`
520
- // (verified further below, after the both-absent refusal) is the only layer
521
- // an attacker forging the metadata cannot also forge, because it is made
522
- // with npm's registry private key, which they do not hold.
523
- //
524
- // `integrityVerified` records whether at least one transport hash actually
525
- // matched the downloaded bytes. The per-skill + manifest Ed25519 signatures
526
- // below authenticate skills/ + manifest.json, but the swap loop ALSO
527
- // replaces data/ and manifest-snapshot.json, which carry no per-content
528
- // signature — a transport hash is their only integrity anchor. When the
529
- // registry metadata supplies NEITHER dist.integrity (sha512 SRI) NOR a
530
- // dist.shasum, both branches are skipped and those bytes would otherwise be
531
- // swapped in with zero verification, so the both-absent case is refused
532
- // below rather than falling through to an unauthenticated swap.
406
+ // Both hashes come from the same metadata response as the tarball URL, so they
407
+ // prove only that the download matches what that response declared.
408
+ // `integrityVerified` gates the unsigned data/ + manifest-snapshot.json swap.
533
409
  let integrityVerified = false;
534
410
  if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
535
411
  const expected = tarballIntegrity.slice("sha512-".length);
@@ -540,13 +416,10 @@ async function main() {
540
416
  }
541
417
  integrityVerified = true;
542
418
  } else if (tarballIntegrity) {
543
- // Non-sha512 SRI (e.g. sha384) — emit a warning but accept; SHA-1 path
544
- // below still gates. A non-sha512 SRI is not actually checked here, so it
545
- // does not count toward integrityVerified.
419
+ // A non-sha512 SRI is not checked, so it does not count toward integrityVerified.
546
420
  progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
547
421
  }
548
422
 
549
- // Verify shasum (registry-provided integrity).
550
423
  if (tarballShasum) {
551
424
  const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
552
425
  if (actual !== tarballShasum) {
@@ -556,13 +429,8 @@ async function main() {
556
429
  integrityVerified = true;
557
430
  }
558
431
 
559
- // Refuse the swap when no transport hash was verifiable. data/ and
560
- // manifest-snapshot.json have no per-content signature, so absent both
561
- // dist.integrity and dist.shasum there is no integrity anchor on those bytes
562
- // and a tampered registry response (or a hostile EXCEPTD_REGISTRY_FIXTURE)
563
- // could inject arbitrary catalog/playbook/index content. `npm update -g`
564
- // runs the full provenance-verified path (sha512 SRI + npm Ed25519 registry
565
- // signatures + sigstore attestations), so it is the correct fallback.
432
+ // Without a verified transport hash, a tampered registry response — or a hostile
433
+ // EXCEPTD_REGISTRY_FIXTURE — injects arbitrary catalog content.
566
434
  if (!integrityVerified) {
567
435
  emit({
568
436
  ok: false,
@@ -572,19 +440,8 @@ async function main() {
572
440
  process.exitCode = 4; return;
573
441
  }
574
442
 
575
- // Verify the npm registry's signature over `<pkg>@<version>:<integrity>`.
576
- // This is the authenticity anchor the transport hashes above cannot be: the
577
- // hashes come from the same (possibly forged) metadata response as the
578
- // tarball URL, but this signature is made with npm's registry private key.
579
- // It transitively authenticates the WHOLE tarball — including the data/ and
580
- // manifest-snapshot.json bytes that carry no per-content Ed25519 signature.
581
- // - valid → strongest anchor present; proceed.
582
- // - invalid / unknown-keyid → a present signature did not verify against a
583
- // pinned npm key (or steers us to an unpinned key);
584
- // this is the forged-metadata signal — refuse.
585
- // - absent / unverifiable → registry supplied no signature (private or
586
- // pre-signing registry); the SHA-512/SHA-1 gates above
587
- // still applied, so warn-and-continue.
443
+ // Made with npm's registry private key, so it survives a forged metadata
444
+ // response; absent/unverifiable falls back to the hash gates above.
588
445
  const regSig = verifyNpmRegistrySignature(PKG_NAME, latestVersion, tarballIntegrity, registrySignatures);
589
446
  if (regSig.status === "invalid" || regSig.status === "unknown-keyid") {
590
447
  emit({
@@ -600,7 +457,6 @@ async function main() {
600
457
  progress(`note: npm registry signature ${regSig.status} (${regSig.reason || "no dist.signatures"}) — relying on transport-hash integrity only`, opts.json);
601
458
  }
602
459
 
603
- // Extract.
604
460
  let tarBuf;
605
461
  try { tarBuf = zlib.gunzipSync(tgzBuf); }
606
462
  catch (e) { emit({ ok: false, error: `gunzip: ${e.message}` }, opts.json); process.exitCode = 4; return; }
@@ -616,9 +472,8 @@ async function main() {
616
472
  process.exitCode = 4; return;
617
473
  }
618
474
 
619
- // Trust anchor = LOCAL public.pem. Compare it to the tarball's public.pem
620
- // by fingerprint; if they differ, refuse the swap (key rotation requires
621
- // a full `npm update -g` so the operator has explicit visibility).
475
+ // The trust anchor is the LOCAL public.pem; a differing tarball fingerprint is
476
+ // a rotation, which requires a full `npm update -g`.
622
477
  const localPubKeyPath = path.join(ROOT, "keys", "public.pem");
623
478
  let localPubKeyText, tarballPubKeyText;
624
479
  try { localPubKeyText = fs.readFileSync(localPubKeyPath, "utf8"); }
@@ -636,17 +491,8 @@ async function main() {
636
491
  process.exitCode = 5; return;
637
492
  }
638
493
 
639
- // v0.12.16: cross-check the local public key against
640
- // keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
641
- // refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
642
- // coordinated attacker who swapped both `keys/public.pem` on the operator's
643
- // host AND the registry tarball passed every check — fingerprints match
644
- // each other but match the attacker's key. The pin in EXPECTED_FINGERPRINT
645
- // is the external trust anchor that closes this gap.
646
- //
647
- // Honors `KEYS_ROTATED=1` env to allow legitimate key rotation without
648
- // re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
649
- // (don't break existing installs whose tree predates the pin file).
494
+ // local ↔ tarball agreement still passes for an attacker who swapped both, so
495
+ // keys/EXPECTED_FINGERPRINT is the external anchor. KEYS_ROTATED=1 bypasses it.
650
496
  const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
651
497
  if (fs.existsSync(expectedFingerprintPath) && process.env.KEYS_ROTATED === "1") {
652
498
  process.emitWarning(
@@ -656,20 +502,9 @@ async function main() {
656
502
  );
657
503
  }
658
504
  if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
659
- // Route through the shared lib/verify loader so a BOM-prefixed pin
660
- // file (Notepad with files.encoding=utf8bom) is tolerated identically
661
- // across every verify site. An inline split-trim-find would retain
662
- // the BOM as part of the first line, which would never match a live
663
- // fingerprint and would block every legitimate refresh-network run.
664
- //
665
- // Narrowed try/catch: only swallow ENOENT / EACCES from the loader
666
- // call (the pin file may have been deleted between existsSync and
667
- // read, or be unreadable by the current uid — both are "warn and
668
- // continue" cases). Any OTHER error (loader bug, unexpected throw)
669
- // must NOT silently fall through to an unpinned refresh — the prior
670
- // catch-all flow was a fail-open vector. The emit({ok:false,...}) +
671
- // exitCode + return block is now OUTSIDE the catch so a parsing
672
- // failure cannot be silently swallowed.
505
+ // The shared loader tolerates a BOM-prefixed pin file; an inline split-trim
506
+ // keeps the BOM on the first line and blocks every legitimate run. Only
507
+ // ENOENT/EACCES is swallowed — any other error must refuse.
673
508
  let expectedFp;
674
509
  let loaderFailedSoftly = false;
675
510
  try {
@@ -677,8 +512,7 @@ async function main() {
677
512
  expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
678
513
  } catch (err) {
679
514
  if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
680
- // Pin file unreadable (race with deletion / permission denied) —
681
- // warn-and-continue is the original behavior for this class.
515
+ // Pin file deleted under us, or not readable by this uid.
682
516
  loaderFailedSoftly = true;
683
517
  } else {
684
518
  emit({
@@ -691,12 +525,8 @@ async function main() {
691
525
  }
692
526
  }
693
527
  if (!loaderFailedSoftly) {
694
- // Pin file present but the loader returned null. The loader refuses
695
- // UTF-16LE / UTF-16BE pin files and any other shape that cannot be
696
- // safely decoded. Treat this as a hard fail: the operator placed a
697
- // pin file but the bytes are not consumable, so we must not fall
698
- // through to an unpinned refresh. Re-save the pin as UTF-8 (with or
699
- // without BOM) and retry.
528
+ // The loader returns null for a pin file it cannot decode; the operator
529
+ // placed a pin, so an unreadable one is a hard fail, not an unpinned refresh.
700
530
  if (expectedFp === null || expectedFp === undefined) {
701
531
  emit({
702
532
  ok: false,
@@ -706,13 +536,8 @@ async function main() {
706
536
  }, opts.json);
707
537
  process.exitCode = 5; return;
708
538
  }
709
- // The pin file is canonically formatted as `SHA256:<base64>`, but
710
- // `fingerprintPublicKey()` returns the raw base64 without the
711
- // `SHA256:` prefix. Comparing the two raw strings would refuse every
712
- // legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
713
- // the prefix from the pin file before compare. lib/verify.js's
714
- // checkExpectedFingerprint() does the symmetric thing (adds the
715
- // prefix to localFp); either side works as long as one is canonical.
539
+ // The pin file is canonically `SHA256:<base64>` while fingerprintPublicKey()
540
+ // returns bare base64, so the prefix comes off before the compare.
716
541
  const expectedFpBase64 = expectedFp.startsWith("SHA256:")
717
542
  ? expectedFp.slice("SHA256:".length)
718
543
  : expectedFp;
@@ -729,25 +554,14 @@ async function main() {
729
554
  }
730
555
  }
731
556
 
732
- // Verify every signed entry in the tarball manifest using the local key.
733
557
  let tarballManifest;
734
558
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
735
559
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
736
560
 
737
- // B + Q P1: verify the top-level manifest_signature against
738
- // the LOCAL public key before honoring any entry in the tarball manifest.
739
- // The previous flow iterated `manifest.skills[].signature` per-skill but
740
- // never authenticated the manifest envelope itself — a coordinated
741
- // attacker who flipped paths/names/atlas_refs on entries already covered
742
- // by per-skill signatures (which sign only the skill body bytes, not the
743
- // metadata around them) could re-shape catalog routing without breaking
744
- // any per-skill signature. The manifest signature closes that gap.
745
- //
746
- // Unlike post-install verify (which warns-and-continues on missing
747
- // signature for legacy-tarball compat), refresh-network REQUIRES the
748
- // signature: this code path is publishing fresh content into the local
749
- // tree, and the tarball must already be ≥ v0.12.17 to have reached the
750
- // registry through the sign-all gate.
561
+ // Authenticate the manifest envelope against the LOCAL public key before
562
+ // honoring any entry in it: per-skill signatures cover only skill body bytes,
563
+ // so paths and names around them could be re-shaped without breaking one. A
564
+ // missing signature refuses here rather than warning.
751
565
  const manifestSigResult = verifyTarballManifestSignature(tarballManifest, localPubKeyText);
752
566
  if (manifestSigResult.status !== "valid") {
753
567
  emit({
@@ -761,20 +575,9 @@ async function main() {
761
575
  process.exitCode = 5; return;
762
576
  }
763
577
 
764
- // v0.12.14: the prior loop iterated `sk.id` + a fixed payload
765
- // path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
766
- // `path` (a forward-slash relative path like `skills/<name>/skill.md`,
767
- // lowercase). Result: the loop matched zero entries; `failures.length === 0`
768
- // and `verifiedCount === 0` and the swap proceeded with `ok: true`. Every
769
- // operator running `exceptd refresh --network` installed unverified bytes.
770
- //
771
- // Fixed shape mirrors lib/verify.js: iterate `manifest.skills[]` by
772
- // `name` + `path` + `signature`. Apply the same CRLF/BOM normalization
773
- // before verify (lib/verify.js normalize() — duplicated here to keep
774
- // this path free of cross-module runtime deps). validateSkillPath()
775
- // is also mirrored to defend against path traversal in a tampered
776
- // tarball manifest before we resolve the path against the extracted
777
- // tree.
578
+ // `path` is the payload's forward-slash location in the tarball, never a name
579
+ // synthesized from the entry's id, and is validated before it resolves against
580
+ // the extracted tree, so a tampered manifest cannot traverse out of it.
778
581
  const localKeyObj = crypto.createPublicKey(localPubKeyText);
779
582
  const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
780
583
  const failures = [];
@@ -817,21 +620,8 @@ async function main() {
817
620
  process.exitCode = 5; return;
818
621
  }
819
622
 
820
- // The swap loop replaces `data/` + `manifest.json` + `manifest-snapshot.json`
821
- // in addition to `skills/`. None of those files are covered by the per-skill
822
- // Ed25519 signature (which signs only the skill body bytes). Their integrity
823
- // anchors are: the SHA-512 SRI + SHA-1 shasum transport hashes (checked
824
- // above; both-absent is refused), AND — when the registry supplies it — the
825
- // npm registry ECDSA-P256 signature over `<pkg>@<version>:<integrity>`
826
- // (verifyNpmRegistrySignature above), which authenticates the whole tarball
827
- // against npm's pinned signing key and cannot be reproduced by a forged
828
- // metadata response. `npm update -g` additionally honors dist.attestations
829
- // (sigstore SLSA provenance), which this data-only path does not consume.
830
- //
831
- // Defense-in-depth: refuse the swap if the manifest skills list doesn't
832
- // exactly match the skill payload entries present in the tarball. A
833
- // malicious tarball that drops/adds a skill outside the manifest no
834
- // longer slips through.
623
+ // A skill payload the manifest does not declare refuses the swap, so a
624
+ // tarball that adds one outside the manifest cannot slip past the loop above.
835
625
  const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
836
626
  const tarballSkillPayloads = entries
837
627
  .map(e => stripPkg(e.name))
@@ -860,12 +650,8 @@ async function main() {
860
650
  return;
861
651
  }
862
652
 
863
- // v0.12.14: the prior swap loop renamed targets one-by-one,
864
- // and a mid-loop failure left the install half-applied with no automatic
865
- // rollback. New shape: rename all old targets into a single backup dir
866
- // first (so the install is empty-of-old before any new content is moved
867
- // in); then rename all new targets in; on failure, walk the backup dir
868
- // in reverse and restore.
653
+ // Every old target moves into one backup dir before any new target moves in;
654
+ // a failure walks completedSteps in reverse to restore.
869
655
  const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
870
656
  let written = 0;
871
657
  let backupDir = null;
@@ -873,9 +659,7 @@ async function main() {
873
659
  try {
874
660
  for (const entry of entries) {
875
661
  const rel = stripPkg(entry.name);
876
- // Scope: only data/, skills/, manifest.json, manifest-snapshot.json.
877
- // Everything else (bin/, lib/, package.json, etc.) is left alone —
878
- // --network is a DATA refresh, not a code refresh.
662
+ // A data refresh: bin/, lib/ and package.json are left alone.
879
663
  if (!(rel === "manifest.json" || rel === "manifest-snapshot.json" ||
880
664
  rel.startsWith("data/") || rel.startsWith("skills/"))) continue;
881
665
  const dst = path.join(stageDir, rel);
@@ -884,16 +668,13 @@ async function main() {
884
668
  written++;
885
669
  }
886
670
 
887
- // v0.12.14: use PID + random suffix in the backup dir name
888
- // so concurrent refresh-network invocations don't collide on the
889
- // millisecond clock.
671
+ // PID plus random suffix: concurrent invocations collide on the ms clock alone.
890
672
  const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
891
673
  backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
892
674
  fs.mkdirSync(backupDir);
893
675
  const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
894
676
 
895
- // Phase A: move all existing targets to backupDir. After this loop
896
- // completes, the install root has none of the replaced targets.
677
+ // Phase A: the install root holds none of the replaced targets after this.
897
678
  for (const target of replaceList) {
898
679
  const dst = path.join(ROOT, target);
899
680
  if (fs.existsSync(dst)) {
@@ -911,8 +692,7 @@ async function main() {
911
692
  }
912
693
 
913
694
  fs.rmSync(stageDir, { recursive: true, force: true });
914
- // Best-effort cleanup of backup dir — keep on disk for one cycle so
915
- // operators can manually roll back if something feels off.
695
+ // The backup dir stays on disk so an operator can roll back by hand.
916
696
  emit({
917
697
  ok: true,
918
698
  local_version: localVersion,
@@ -927,15 +707,12 @@ async function main() {
927
707
  message: `refreshed catalog from v${localVersion} → v${latestVersion} (${verifiedCount}/${skills.length} skill signatures verified, npm registry signature: ${regSig.status}). Backup at ${path.relative(ROOT, backupDir)} — safe to remove after verifying the new run.`,
928
708
  }, opts.json);
929
709
  } catch (e) {
930
- // v0.12.14: walk completedSteps in reverse to undo partial work.
931
710
  const rollbackErrors = [];
932
711
  for (const step of [...completedSteps].reverse()) {
933
712
  try {
934
713
  if (step.kind === "install") {
935
- // Remove the newly-installed copy.
936
714
  fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
937
715
  } else if (step.kind === "backup" && backupDir) {
938
- // Restore from backup.
939
716
  const src = path.join(backupDir, step.target);
940
717
  const dst = path.join(ROOT, step.target);
941
718
  if (fs.existsSync(src)) fs.renameSync(src, dst);
@@ -969,19 +746,10 @@ if (require.main === module) {
969
746
  module.exports = {
970
747
  parseTar,
971
748
  fingerprintPublicKey,
972
- // Exported for tests/normalize-contract.test.js so the byte-stability
973
- // contract can be asserted across all four normalize() implementations
974
- // (lib/sign.js, lib/verify.js, lib/refresh-network.js,
975
- // scripts/verify-shipped-tarball.js).
976
749
  normalizeSkillBytes,
977
- // Exported for in-process tests of the refresh path's manifest envelope
978
- // check.
979
750
  verifyTarballManifestSignature,
980
751
  canonicalManifestBytesForRefresh,
981
- // Exported for in-process tests of the tarball fetch-destination allowlist.
982
752
  isAllowedTarballHost,
983
- // Exported for in-process tests of the npm registry signature verification
984
- // over `<pkg>@<version>:<integrity>`.
985
753
  verifyNpmRegistrySignature,
986
754
  NPM_REGISTRY_KEYS,
987
755
  };