@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.
- package/CHANGELOG.md +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- 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 +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- 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 +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- 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 -152
- 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 +35 -114
- 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 +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- 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 +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- 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 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- 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 +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- 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 +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- 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 +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- 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 -140
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);
|
|
@@ -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
|
-
//
|
|
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
|
|
218
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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).
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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).
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
620
|
-
//
|
|
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
|
-
//
|
|
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).
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
};
|