@blamejs/exceptd-skills 0.19.33 → 0.19.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/bin/exceptd.js +895 -2828
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -155
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +39 -113
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -413
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -141
package/lib/refresh-network.js
CHANGED
|
@@ -2,35 +2,10 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
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
|
|
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
|
-
//
|
|
54
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
133
|
-
// (
|
|
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
|
|
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
|
-
//
|
|
155
|
-
//
|
|
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);
|
|
@@ -171,10 +116,9 @@ function getBufferOnce(url, timeoutMs) {
|
|
|
171
116
|
if (!isAllowedTarballHost(url)) {
|
|
172
117
|
return reject(new Error(`refusing to fetch tarball from non-allowlisted host: ${u.hostname}`));
|
|
173
118
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
})();
|
|
119
|
+
// Same reader as the buffered-length check, so the streaming cap and the
|
|
120
|
+
// post-download cap cannot drift apart when the default changes.
|
|
121
|
+
const cap = tarballSizeCap();
|
|
178
122
|
const req = https.get({
|
|
179
123
|
host: u.host, path: u.pathname + u.search,
|
|
180
124
|
headers: { "User-Agent": "exceptd/refresh-network" },
|
|
@@ -188,8 +132,6 @@ function getBufferOnce(url, timeoutMs) {
|
|
|
188
132
|
}
|
|
189
133
|
const chunks = [];
|
|
190
134
|
let total = 0;
|
|
191
|
-
// v0.12.14: enforce streaming size cap so a hostile
|
|
192
|
-
// registry CDN can't stream gigabytes into RAM.
|
|
193
135
|
res.on("data", (c) => {
|
|
194
136
|
total += c.length;
|
|
195
137
|
if (total > cap) {
|
|
@@ -205,39 +147,29 @@ function getBufferOnce(url, timeoutMs) {
|
|
|
205
147
|
});
|
|
206
148
|
}
|
|
207
149
|
|
|
208
|
-
//
|
|
209
|
-
// host-allowlist rejection + size-cap carry no statusCode/code, so a permanent
|
|
210
|
-
// refusal is not retried; a transient 5xx / reset / timeout is.
|
|
150
|
+
// Same classifier as getJson: the host refusal and the size cap are permanent.
|
|
211
151
|
function getBuffer(url, timeoutMs) {
|
|
212
152
|
const { withRetry } = require("../vendor/blamejs/retry.js"); // lazy — see getJson
|
|
213
153
|
return withRetry(() => getBufferOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
|
|
214
154
|
}
|
|
215
155
|
|
|
216
156
|
/**
|
|
217
|
-
* Parse
|
|
218
|
-
*
|
|
219
|
-
* Skips PaxHeader entries; treats long-link entries (type L) by stitching
|
|
220
|
-
* the next entry's name from the long-link body.
|
|
157
|
+
* Parse an uncompressed tar buffer into [{ name, body }] — the GNU ustar
|
|
158
|
+
* variant npm produces; a type-L entry supplies the following entry's name.
|
|
221
159
|
*/
|
|
222
160
|
function parseTar(buf) {
|
|
223
161
|
const entries = [];
|
|
224
162
|
let offset = 0;
|
|
225
163
|
let pendingLongName = null;
|
|
226
|
-
//
|
|
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.
|
|
164
|
+
// An entry whose name escapes the extraction root is dropped.
|
|
232
165
|
const isSafeName = (n) => {
|
|
233
166
|
if (typeof n !== "string" || n.length === 0) return false;
|
|
234
|
-
// Reject absolute paths AND any segment that is exactly ".."
|
|
235
167
|
if (/^[\\/]/.test(n) || /^[A-Za-z]:[\\/]/.test(n)) return false;
|
|
236
168
|
return !n.split(/[\\/]/).some((seg) => seg === "..");
|
|
237
169
|
};
|
|
238
170
|
while (offset + 512 <= buf.length) {
|
|
239
171
|
const block = buf.subarray(offset, offset + 512);
|
|
240
|
-
//
|
|
172
|
+
// An all-zero block is the end-of-archive marker.
|
|
241
173
|
if (block.every((b) => b === 0)) break;
|
|
242
174
|
let name = block.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
|
243
175
|
const sizeStr = block.subarray(124, 136).toString("utf8").replace(/\0.*$|\s+$/g, "").trim();
|
|
@@ -255,7 +187,6 @@ function parseTar(buf) {
|
|
|
255
187
|
entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
|
|
256
188
|
}
|
|
257
189
|
}
|
|
258
|
-
// round up to 512
|
|
259
190
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
260
191
|
}
|
|
261
192
|
return entries;
|
|
@@ -275,13 +206,9 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
|
|
|
275
206
|
} catch { return false; }
|
|
276
207
|
}
|
|
277
208
|
|
|
278
|
-
// Pinned npm registry signing keys (https://registry.npmjs.org/-/npm/v1/keys)
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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.
|
|
209
|
+
// Pinned npm registry signing keys (https://registry.npmjs.org/-/npm/v1/keys),
|
|
210
|
+
// as base64 SPKI DER. Pinned in-tree because fetching them over the channel
|
|
211
|
+
// they authenticate would be circular.
|
|
285
212
|
const NPM_REGISTRY_KEYS = Object.freeze({
|
|
286
213
|
"SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA":
|
|
287
214
|
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==",
|
|
@@ -289,18 +216,8 @@ const NPM_REGISTRY_KEYS = Object.freeze({
|
|
|
289
216
|
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==",
|
|
290
217
|
});
|
|
291
218
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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).
|
|
219
|
+
// Returns { status }: valid | absent | unverifiable | unknown-keyid | invalid.
|
|
220
|
+
// Callers refuse on invalid and unknown-keyid, and warn-and-continue otherwise.
|
|
304
221
|
function verifyNpmRegistrySignature(pkg, version, integrity, signatures) {
|
|
305
222
|
if (!Array.isArray(signatures) || signatures.length === 0) return { status: "absent" };
|
|
306
223
|
if (typeof integrity !== "string" || integrity.length === 0) {
|
|
@@ -325,24 +242,17 @@ function verifyNpmRegistrySignature(pkg, version, integrity, signatures) {
|
|
|
325
242
|
return { status: "invalid", reason: "no dist.signatures[] entry verified against the pinned npm registry key" };
|
|
326
243
|
}
|
|
327
244
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
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.
|
|
245
|
+
// CRLF/BOM normalization, duplicated to keep this module dependency-free. Any
|
|
246
|
+
// change must be mirrored in lib/verify.js, lib/sign.js and
|
|
247
|
+
// scripts/verify-shipped-tarball.js; tests/normalize-contract.test.js enforces it.
|
|
334
248
|
function normalizeSkillBytes(buf) {
|
|
335
249
|
let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
|
336
250
|
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
337
251
|
return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
|
|
338
252
|
}
|
|
339
253
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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.
|
|
254
|
+
// Canonical-bytes computation must stay in lockstep with
|
|
255
|
+
// canonicalManifestBytes() in lib/sign.js and lib/verify.js.
|
|
346
256
|
function canonicalizeForRefresh(value) {
|
|
347
257
|
if (Array.isArray(value)) return value.map(canonicalizeForRefresh);
|
|
348
258
|
if (value && typeof value === "object") {
|
|
@@ -385,7 +295,7 @@ function verifyTarballManifestSignature(manifest, publicKeyPem) {
|
|
|
385
295
|
return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 manifest signature did not verify against local public.pem" };
|
|
386
296
|
}
|
|
387
297
|
|
|
388
|
-
//
|
|
298
|
+
// Mirrors lib/verify.js validateSkillPath().
|
|
389
299
|
function validateManifestSkillPath(skillPath) {
|
|
390
300
|
if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
391
301
|
if (skillPath.includes("\\")) throw new Error(`manifest skill.path must use forward slashes: ${JSON.stringify(skillPath)}`);
|
|
@@ -394,10 +304,7 @@ function validateManifestSkillPath(skillPath) {
|
|
|
394
304
|
return skillPath;
|
|
395
305
|
}
|
|
396
306
|
|
|
397
|
-
//
|
|
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.
|
|
307
|
+
// Buffered-tarball cap; EXCEPTD_TARBALL_SIZE_CAP_BYTES overrides it.
|
|
401
308
|
const TARBALL_SIZE_CAP_BYTES_DEFAULT = 200 * 1024 * 1024;
|
|
402
309
|
function tarballSizeCap() {
|
|
403
310
|
const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
|
|
@@ -409,15 +316,9 @@ async function main() {
|
|
|
409
316
|
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
410
317
|
const localVersion = localPkg.version;
|
|
411
318
|
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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.
|
|
319
|
+
// Refused before any network attempt, and unconditional with respect to
|
|
320
|
+
// EXCEPTD_REGISTRY_FIXTURE — the fixture stubs only the /latest read, so
|
|
321
|
+
// honoring it here would let the tarball fetch reach the network anyway.
|
|
421
322
|
if (opts.airGap) {
|
|
422
323
|
emit({
|
|
423
324
|
ok: false,
|
|
@@ -432,11 +333,8 @@ async function main() {
|
|
|
432
333
|
let meta;
|
|
433
334
|
try {
|
|
434
335
|
if (process.env.EXCEPTD_REGISTRY_FIXTURE) {
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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).
|
|
336
|
+
// Fixture shape mirrors upstream-check's /<pkg>/latest response: version +
|
|
337
|
+
// dist.tarball + dist.shasum, or version alone for the already-at-latest path.
|
|
440
338
|
meta = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
|
|
441
339
|
} else {
|
|
442
340
|
meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace(/%40/g, "@").replace(/%2F/g, "/")}/latest`, opts.timeoutMs);
|
|
@@ -461,9 +359,7 @@ async function main() {
|
|
|
461
359
|
return;
|
|
462
360
|
}
|
|
463
361
|
|
|
464
|
-
//
|
|
465
|
-
// root-owned dir; refusing to fail-then-leave-partial is the safer
|
|
466
|
-
// contract.
|
|
362
|
+
// Probe writability before anything is fetched — failing here beats failing mid-swap.
|
|
467
363
|
const writeProbe = path.join(ROOT, `.write-probe-${process.pid}`);
|
|
468
364
|
try {
|
|
469
365
|
fs.writeFileSync(writeProbe, "");
|
|
@@ -477,13 +373,9 @@ async function main() {
|
|
|
477
373
|
process.exitCode = 3; return;
|
|
478
374
|
}
|
|
479
375
|
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
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.
|
|
376
|
+
// Guard the same binding the URL is built from, so the semver check is the only
|
|
377
|
+
// path a metadata value reaches the fetch. A dist.tarball divergence is noted,
|
|
378
|
+
// not fatal — authenticity rests on the hash and signature chain below.
|
|
487
379
|
const safeVersion = typeof latestVersion === "string" ? latestVersion : "";
|
|
488
380
|
if (!SEMVER_RE.test(safeVersion)) {
|
|
489
381
|
emit({ ok: false, error: `registry metadata version is not valid semver: ${safeVersion.slice(0, 64)}` }, opts.json);
|
|
@@ -504,32 +396,15 @@ async function main() {
|
|
|
504
396
|
process.exitCode = 2; return;
|
|
505
397
|
}
|
|
506
398
|
|
|
507
|
-
// v0.12.14: defense-in-depth tarball size cap.
|
|
508
399
|
const sizeCap = tarballSizeCap();
|
|
509
400
|
if (tgzBuf.length > sizeCap) {
|
|
510
401
|
emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
|
|
511
402
|
process.exitCode = 4; return;
|
|
512
403
|
}
|
|
513
404
|
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
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.
|
|
405
|
+
// Both hashes come from the same metadata response as the tarball URL, so they
|
|
406
|
+
// prove only that the download matches what that response declared.
|
|
407
|
+
// `integrityVerified` gates the unsigned data/ + manifest-snapshot.json swap.
|
|
533
408
|
let integrityVerified = false;
|
|
534
409
|
if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
|
|
535
410
|
const expected = tarballIntegrity.slice("sha512-".length);
|
|
@@ -540,13 +415,10 @@ async function main() {
|
|
|
540
415
|
}
|
|
541
416
|
integrityVerified = true;
|
|
542
417
|
} else if (tarballIntegrity) {
|
|
543
|
-
//
|
|
544
|
-
// below still gates. A non-sha512 SRI is not actually checked here, so it
|
|
545
|
-
// does not count toward integrityVerified.
|
|
418
|
+
// A non-sha512 SRI is not checked, so it does not count toward integrityVerified.
|
|
546
419
|
progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
|
|
547
420
|
}
|
|
548
421
|
|
|
549
|
-
// Verify shasum (registry-provided integrity).
|
|
550
422
|
if (tarballShasum) {
|
|
551
423
|
const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
|
|
552
424
|
if (actual !== tarballShasum) {
|
|
@@ -556,13 +428,8 @@ async function main() {
|
|
|
556
428
|
integrityVerified = true;
|
|
557
429
|
}
|
|
558
430
|
|
|
559
|
-
//
|
|
560
|
-
//
|
|
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.
|
|
431
|
+
// Without a verified transport hash, a tampered registry response — or a hostile
|
|
432
|
+
// EXCEPTD_REGISTRY_FIXTURE — injects arbitrary catalog content.
|
|
566
433
|
if (!integrityVerified) {
|
|
567
434
|
emit({
|
|
568
435
|
ok: false,
|
|
@@ -572,19 +439,8 @@ async function main() {
|
|
|
572
439
|
process.exitCode = 4; return;
|
|
573
440
|
}
|
|
574
441
|
|
|
575
|
-
//
|
|
576
|
-
//
|
|
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.
|
|
442
|
+
// Made with npm's registry private key, so it survives a forged metadata
|
|
443
|
+
// response; absent/unverifiable falls back to the hash gates above.
|
|
588
444
|
const regSig = verifyNpmRegistrySignature(PKG_NAME, latestVersion, tarballIntegrity, registrySignatures);
|
|
589
445
|
if (regSig.status === "invalid" || regSig.status === "unknown-keyid") {
|
|
590
446
|
emit({
|
|
@@ -600,7 +456,6 @@ async function main() {
|
|
|
600
456
|
progress(`note: npm registry signature ${regSig.status} (${regSig.reason || "no dist.signatures"}) — relying on transport-hash integrity only`, opts.json);
|
|
601
457
|
}
|
|
602
458
|
|
|
603
|
-
// Extract.
|
|
604
459
|
let tarBuf;
|
|
605
460
|
try { tarBuf = zlib.gunzipSync(tgzBuf); }
|
|
606
461
|
catch (e) { emit({ ok: false, error: `gunzip: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
@@ -616,9 +471,8 @@ async function main() {
|
|
|
616
471
|
process.exitCode = 4; return;
|
|
617
472
|
}
|
|
618
473
|
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
// a full `npm update -g` so the operator has explicit visibility).
|
|
474
|
+
// The trust anchor is the LOCAL public.pem; a differing tarball fingerprint is
|
|
475
|
+
// a rotation, which requires a full `npm update -g`.
|
|
622
476
|
const localPubKeyPath = path.join(ROOT, "keys", "public.pem");
|
|
623
477
|
let localPubKeyText, tarballPubKeyText;
|
|
624
478
|
try { localPubKeyText = fs.readFileSync(localPubKeyPath, "utf8"); }
|
|
@@ -636,17 +490,8 @@ async function main() {
|
|
|
636
490
|
process.exitCode = 5; return;
|
|
637
491
|
}
|
|
638
492
|
|
|
639
|
-
//
|
|
640
|
-
// keys/EXPECTED_FINGERPRINT
|
|
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).
|
|
493
|
+
// local ↔ tarball agreement still passes for an attacker who swapped both, so
|
|
494
|
+
// keys/EXPECTED_FINGERPRINT is the external anchor. KEYS_ROTATED=1 bypasses it.
|
|
650
495
|
const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
|
|
651
496
|
if (fs.existsSync(expectedFingerprintPath) && process.env.KEYS_ROTATED === "1") {
|
|
652
497
|
process.emitWarning(
|
|
@@ -656,20 +501,9 @@ async function main() {
|
|
|
656
501
|
);
|
|
657
502
|
}
|
|
658
503
|
if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
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.
|
|
504
|
+
// The shared loader tolerates a BOM-prefixed pin file; an inline split-trim
|
|
505
|
+
// keeps the BOM on the first line and blocks every legitimate run. Only
|
|
506
|
+
// ENOENT/EACCES is swallowed — any other error must refuse.
|
|
673
507
|
let expectedFp;
|
|
674
508
|
let loaderFailedSoftly = false;
|
|
675
509
|
try {
|
|
@@ -677,8 +511,7 @@ async function main() {
|
|
|
677
511
|
expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
|
|
678
512
|
} catch (err) {
|
|
679
513
|
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
|
680
|
-
// Pin file
|
|
681
|
-
// warn-and-continue is the original behavior for this class.
|
|
514
|
+
// Pin file deleted under us, or not readable by this uid.
|
|
682
515
|
loaderFailedSoftly = true;
|
|
683
516
|
} else {
|
|
684
517
|
emit({
|
|
@@ -691,12 +524,8 @@ async function main() {
|
|
|
691
524
|
}
|
|
692
525
|
}
|
|
693
526
|
if (!loaderFailedSoftly) {
|
|
694
|
-
//
|
|
695
|
-
//
|
|
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.
|
|
527
|
+
// The loader returns null for a pin file it cannot decode; the operator
|
|
528
|
+
// placed a pin, so an unreadable one is a hard fail, not an unpinned refresh.
|
|
700
529
|
if (expectedFp === null || expectedFp === undefined) {
|
|
701
530
|
emit({
|
|
702
531
|
ok: false,
|
|
@@ -706,13 +535,8 @@ async function main() {
|
|
|
706
535
|
}, opts.json);
|
|
707
536
|
process.exitCode = 5; return;
|
|
708
537
|
}
|
|
709
|
-
// The pin file is canonically
|
|
710
|
-
//
|
|
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.
|
|
538
|
+
// The pin file is canonically `SHA256:<base64>` while fingerprintPublicKey()
|
|
539
|
+
// returns bare base64, so the prefix comes off before the compare.
|
|
716
540
|
const expectedFpBase64 = expectedFp.startsWith("SHA256:")
|
|
717
541
|
? expectedFp.slice("SHA256:".length)
|
|
718
542
|
: expectedFp;
|
|
@@ -729,25 +553,14 @@ async function main() {
|
|
|
729
553
|
}
|
|
730
554
|
}
|
|
731
555
|
|
|
732
|
-
// Verify every signed entry in the tarball manifest using the local key.
|
|
733
556
|
let tarballManifest;
|
|
734
557
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
735
558
|
catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
736
559
|
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
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.
|
|
560
|
+
// Authenticate the manifest envelope against the LOCAL public key before
|
|
561
|
+
// honoring any entry in it: per-skill signatures cover only skill body bytes,
|
|
562
|
+
// so paths and names around them could be re-shaped without breaking one. A
|
|
563
|
+
// missing signature refuses here rather than warning.
|
|
751
564
|
const manifestSigResult = verifyTarballManifestSignature(tarballManifest, localPubKeyText);
|
|
752
565
|
if (manifestSigResult.status !== "valid") {
|
|
753
566
|
emit({
|
|
@@ -761,20 +574,9 @@ async function main() {
|
|
|
761
574
|
process.exitCode = 5; return;
|
|
762
575
|
}
|
|
763
576
|
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
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.
|
|
577
|
+
// `path` is the payload's forward-slash location in the tarball, never a name
|
|
578
|
+
// synthesized from the entry's id, and is validated before it resolves against
|
|
579
|
+
// the extracted tree, so a tampered manifest cannot traverse out of it.
|
|
778
580
|
const localKeyObj = crypto.createPublicKey(localPubKeyText);
|
|
779
581
|
const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
|
|
780
582
|
const failures = [];
|
|
@@ -817,21 +619,8 @@ async function main() {
|
|
|
817
619
|
process.exitCode = 5; return;
|
|
818
620
|
}
|
|
819
621
|
|
|
820
|
-
//
|
|
821
|
-
//
|
|
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.
|
|
622
|
+
// A skill payload the manifest does not declare refuses the swap, so a
|
|
623
|
+
// tarball that adds one outside the manifest cannot slip past the loop above.
|
|
835
624
|
const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
|
|
836
625
|
const tarballSkillPayloads = entries
|
|
837
626
|
.map(e => stripPkg(e.name))
|
|
@@ -860,12 +649,8 @@ async function main() {
|
|
|
860
649
|
return;
|
|
861
650
|
}
|
|
862
651
|
|
|
863
|
-
//
|
|
864
|
-
//
|
|
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.
|
|
652
|
+
// Every old target moves into one backup dir before any new target moves in;
|
|
653
|
+
// a failure walks completedSteps in reverse to restore.
|
|
869
654
|
const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
|
|
870
655
|
let written = 0;
|
|
871
656
|
let backupDir = null;
|
|
@@ -873,9 +658,7 @@ async function main() {
|
|
|
873
658
|
try {
|
|
874
659
|
for (const entry of entries) {
|
|
875
660
|
const rel = stripPkg(entry.name);
|
|
876
|
-
//
|
|
877
|
-
// Everything else (bin/, lib/, package.json, etc.) is left alone —
|
|
878
|
-
// --network is a DATA refresh, not a code refresh.
|
|
661
|
+
// A data refresh: bin/, lib/ and package.json are left alone.
|
|
879
662
|
if (!(rel === "manifest.json" || rel === "manifest-snapshot.json" ||
|
|
880
663
|
rel.startsWith("data/") || rel.startsWith("skills/"))) continue;
|
|
881
664
|
const dst = path.join(stageDir, rel);
|
|
@@ -884,16 +667,13 @@ async function main() {
|
|
|
884
667
|
written++;
|
|
885
668
|
}
|
|
886
669
|
|
|
887
|
-
//
|
|
888
|
-
// so concurrent refresh-network invocations don't collide on the
|
|
889
|
-
// millisecond clock.
|
|
670
|
+
// PID plus random suffix: concurrent invocations collide on the ms clock alone.
|
|
890
671
|
const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
891
672
|
backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
|
|
892
673
|
fs.mkdirSync(backupDir);
|
|
893
674
|
const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
|
|
894
675
|
|
|
895
|
-
// Phase A:
|
|
896
|
-
// completes, the install root has none of the replaced targets.
|
|
676
|
+
// Phase A: the install root holds none of the replaced targets after this.
|
|
897
677
|
for (const target of replaceList) {
|
|
898
678
|
const dst = path.join(ROOT, target);
|
|
899
679
|
if (fs.existsSync(dst)) {
|
|
@@ -911,8 +691,7 @@ async function main() {
|
|
|
911
691
|
}
|
|
912
692
|
|
|
913
693
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
914
|
-
//
|
|
915
|
-
// operators can manually roll back if something feels off.
|
|
694
|
+
// The backup dir stays on disk so an operator can roll back by hand.
|
|
916
695
|
emit({
|
|
917
696
|
ok: true,
|
|
918
697
|
local_version: localVersion,
|
|
@@ -927,15 +706,12 @@ async function main() {
|
|
|
927
706
|
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
707
|
}, opts.json);
|
|
929
708
|
} catch (e) {
|
|
930
|
-
// v0.12.14: walk completedSteps in reverse to undo partial work.
|
|
931
709
|
const rollbackErrors = [];
|
|
932
710
|
for (const step of [...completedSteps].reverse()) {
|
|
933
711
|
try {
|
|
934
712
|
if (step.kind === "install") {
|
|
935
|
-
// Remove the newly-installed copy.
|
|
936
713
|
fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
|
|
937
714
|
} else if (step.kind === "backup" && backupDir) {
|
|
938
|
-
// Restore from backup.
|
|
939
715
|
const src = path.join(backupDir, step.target);
|
|
940
716
|
const dst = path.join(ROOT, step.target);
|
|
941
717
|
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
@@ -969,19 +745,10 @@ if (require.main === module) {
|
|
|
969
745
|
module.exports = {
|
|
970
746
|
parseTar,
|
|
971
747
|
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
748
|
normalizeSkillBytes,
|
|
977
|
-
// Exported for in-process tests of the refresh path's manifest envelope
|
|
978
|
-
// check.
|
|
979
749
|
verifyTarballManifestSignature,
|
|
980
750
|
canonicalManifestBytesForRefresh,
|
|
981
|
-
// Exported for in-process tests of the tarball fetch-destination allowlist.
|
|
982
751
|
isAllowedTarballHost,
|
|
983
|
-
// Exported for in-process tests of the npm registry signature verification
|
|
984
|
-
// over `<pkg>@<version>:<integrity>`.
|
|
985
752
|
verifyNpmRegistrySignature,
|
|
986
753
|
NPM_REGISTRY_KEYS,
|
|
987
754
|
};
|