amicus 4.9.5 → 4.9.7
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +238 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +36 -7
- package/docs/configuration.md +119 -11
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +103 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +120 -135
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +175 -0
- package/src/sidecar/electron-native-rescue.js +270 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +95 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +231 -0
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +146 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Electron CONTROLLED provision — the pinned download, and
|
|
3
|
-
*
|
|
2
|
+
* Electron CONTROLLED provision — the pinned download, and the fence that says
|
|
3
|
+
* whether a refused cache artifact may be deleted.
|
|
4
4
|
*
|
|
5
5
|
* Split out of electron-install.js because that file sits at the repo's 300-line
|
|
6
|
-
* gate and cannot grow
|
|
7
|
-
* electron-
|
|
8
|
-
*
|
|
6
|
+
* gate and cannot grow; the refusal MESSAGES were split out of this file, into
|
|
7
|
+
* ./electron-refuse, for the same reason. The require arrow is
|
|
8
|
+
* electron-install -> electron-provision -> {electron-custody, electron-layout,
|
|
9
|
+
* electron-refuse, electron-trust} and must never point back. Those four are
|
|
10
|
+
* near-leaves required by both this module and electron-install.js — and by
|
|
11
|
+
* ./electron-repair-cache, which requires THIS module for `mayDeleteRejectedZip`
|
|
12
|
+
* and is required only by electron-install.js, so the arrow stays acyclic.
|
|
9
13
|
*
|
|
10
14
|
* @module sidecar/electron-provision
|
|
11
15
|
*/
|
|
@@ -15,7 +19,12 @@
|
|
|
15
19
|
const path = require('path');
|
|
16
20
|
|
|
17
21
|
const { resolveCacheRoots } = require('./electron-cache');
|
|
18
|
-
const {
|
|
22
|
+
const { readArtifactBytes } = require('./electron-custody');
|
|
23
|
+
const { extractBytesToDist } = require('./electron-layout');
|
|
24
|
+
const { withNativeRescue } = require('./electron-native-rescue');
|
|
25
|
+
const { refuseUnreadableArtifact, rejectDownloadedZip } = require('./electron-refuse');
|
|
26
|
+
const { withScrubbedRepoEnv } = require('./electron-env-scrub');
|
|
27
|
+
const { artifactFileName, expectedDigest, verifyArtifactBytes } = require('./electron-trust');
|
|
19
28
|
const { containsOnDisk } = require('../utils/path-fence');
|
|
20
29
|
|
|
21
30
|
/** Best-effort cache root for downloadArtifact (first resolved root). */
|
|
@@ -25,8 +34,9 @@ function cacheRootFor(env = process.env) {
|
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
|
|
28
|
-
* api install.js uses
|
|
29
|
-
* the caller verify isElectronUsable().
|
|
37
|
+
* api install.js uses, READ IT ONCE into memory, hash THOSE BYTES ourselves,
|
|
38
|
+
* extract THOSE BYTES offline, and let the caller verify isElectronUsable().
|
|
39
|
+
* No blind install.js spawn, and no path resolved a second time.
|
|
30
40
|
*
|
|
31
41
|
* C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
|
|
32
42
|
* @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
|
|
@@ -48,11 +58,40 @@ function cacheRootFor(env = process.env) {
|
|
|
48
58
|
* SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
|
|
49
59
|
* so it is stated out loud on stderr every time rather than happening quietly —
|
|
50
60
|
* and it is reachable ONLY through a bare env name a repository cannot plant.
|
|
51
|
-
*
|
|
61
|
+
*
|
|
62
|
+
* F#2 — AMICUS HASHES WHAT IT DOWNLOADED, ITSELF. Sending `checksums` records
|
|
63
|
+
* that a table went out; it does not record that the bytes reaching the
|
|
64
|
+
* extractor matched it. @electron/get validates in its own temp dir and then
|
|
65
|
+
* RENAMES the artifact into the cache root, handing back THAT path — a path the
|
|
66
|
+
* same cache-dir writer the digest gate exists to stop can swap before amicus
|
|
67
|
+
* opens it. MEASURED before this change: with the swap fired inside amicus's own
|
|
68
|
+
* post-download window, `BYTES EXTRACTED: "POISONED-BYTES"` and
|
|
69
|
+
* `{"repaired":true}`.
|
|
70
|
+
*
|
|
71
|
+
* THE SECOND ROUND MOVED WHERE THAT HASH HAPPENS, because the first answer was
|
|
72
|
+
* not enough. It staged a private COPY and hashed the copy — and seat D1 showed
|
|
73
|
+
* that copy is discoverable and openable by the same uid, so the race simply
|
|
74
|
+
* moved onto the staged path. There is no copy now: the downloaded path is read
|
|
75
|
+
* ONCE into a Buffer, that Buffer is hashed, and that Buffer is extracted. The
|
|
76
|
+
* identical three steps run on the cache route (./electron-repair-cache), which
|
|
77
|
+
* is what makes `pinned` mean what its name says.
|
|
78
|
+
*
|
|
79
|
+
* F#6/F#8 — AND IT FAILS CLOSED. When the bytes could not be read at all, this
|
|
80
|
+
* route used to extract the unread path anyway while the only line on screen
|
|
81
|
+
* said they would not be extracted. It refuses, in the cache route's own words.
|
|
82
|
+
*
|
|
83
|
+
* F3 — AND AN UNPINNED SUCCESS IS MARKED, NOT ONLY LOGGED (seats C1 + B3). Both
|
|
84
|
+
* `CHANGELOG.md` and `docs/troubleshooting.md` promise that an artifact no
|
|
85
|
+
* published digest covers is "extracted and marked `unverified`". v4.9.5 kept
|
|
86
|
+
* that promise on the CACHE route only. `repairElectron` folds `unverified:true`
|
|
87
|
+
* into its result whenever `pinned` is false.
|
|
88
|
+
* @returns {Promise<{pinned:boolean, refused?:object}>} pinned:false = these bytes
|
|
89
|
+
* were vouched for only by the mirror that served them; `refused` = a result
|
|
90
|
+
* shape the caller must return as-is, nothing was extracted.
|
|
52
91
|
*/
|
|
53
92
|
async function controlledProvision({
|
|
54
|
-
electronDir, platform, arch, version, anchor, downloadArtifact, extract,
|
|
55
|
-
fs, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
|
|
93
|
+
electronDir, platform, arch, version, anchor, downloadArtifact, extract,
|
|
94
|
+
fs, spawn, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
|
|
56
95
|
}) {
|
|
57
96
|
const fileName = artifactFileName({ version, platform, arch });
|
|
58
97
|
let digest = expectedDigest(anchor, fileName);
|
|
@@ -60,18 +99,82 @@ async function controlledProvision({
|
|
|
60
99
|
log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
|
|
61
100
|
log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
|
|
62
101
|
digest = null;
|
|
102
|
+
} else if (!digest) {
|
|
103
|
+
log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
|
|
104
|
+
log('[amicus] checksums.json entry for it), so the download could not be pinned: its bytes');
|
|
105
|
+
log('[amicus] are checked against the SHASUMS256.txt the mirror itself serves. The result');
|
|
106
|
+
log('[amicus] is reported as unverified.');
|
|
63
107
|
}
|
|
64
|
-
|
|
108
|
+
// D5: @electron/get runs IN THIS PROCESS and reads the same repo-plantable
|
|
109
|
+
// npm_config_electron_* / npm_package_config_electron_* names install.js does.
|
|
110
|
+
// The scrub is held across the synchronous prefix ONLY, and the promise is
|
|
111
|
+
// returned unawaited so no unrelated caller ever sees a scrubbed environment.
|
|
112
|
+
//
|
|
113
|
+
// WHAT THAT DOES AND DOES NOT COVER, MEASURED against the installed 5.0.0 and
|
|
114
|
+
// re-measured by tests/electron-env-scrub-get5-contract.test.js on every run
|
|
115
|
+
// (round 3, seat B1 — the previous claim here was reasoned, never run):
|
|
116
|
+
// every read that decides the ARTIFACT's URL lands inside the window on BOTH
|
|
117
|
+
// routes (20 of 20, 0 after the restore), so a planted mirror cannot move this
|
|
118
|
+
// download. When `digest` is null there is no `checksums` table, and
|
|
119
|
+
// @electron/get then recursively downloads SHASUMS256.txt AFTER awaits with
|
|
120
|
+
// the environment restored — 13 planted reads, measured. That is
|
|
121
|
+
// availability-only: the zip's URL is already fixed, so the planted mirror can
|
|
122
|
+
// only serve a checksum file that disagrees with official bytes and FAIL the
|
|
123
|
+
// download. See electron-env-scrub.js for the three closures rejected and why.
|
|
124
|
+
//
|
|
125
|
+
// IT SCRUBS `process.env`, NOT THIS FUNCTION'S `env` ARGUMENT, and that is the
|
|
126
|
+
// point. `env` is an injectable input to cache-root RESOLUTION; the env
|
|
127
|
+
// @electron/get actually reads is `process.env`, and the library offers no way
|
|
128
|
+
// to change that. Threading `env` here would make the control silently do
|
|
129
|
+
// nothing for any caller that passed a synthetic one — a guard aimed at a
|
|
130
|
+
// surface its target never reads.
|
|
131
|
+
const zip = await withScrubbedRepoEnv(() => downloadArtifact({
|
|
65
132
|
version,
|
|
66
133
|
artifactName: 'electron',
|
|
134
|
+
// MEASURED, and stated because the surrounding prose used to claim
|
|
135
|
+
// otherwise: `force` is DEAD in @electron/get 5.0.0 — `effectiveCacheMode`
|
|
136
|
+
// never reads it — so this call can return a CACHE PATH with no network
|
|
137
|
+
// fetch at all. It is left in place because removing it changes nothing
|
|
138
|
+
// today and a later version may honour it again. What makes this route
|
|
139
|
+
// sound is not freshness: it is that amicus reads and hashes whatever path
|
|
140
|
+
// comes back, in its own memory.
|
|
67
141
|
force: true,
|
|
68
142
|
cacheRoot: cacheRootFor(env),
|
|
69
143
|
platform,
|
|
70
144
|
arch,
|
|
71
145
|
...(digest ? { checksums: { [fileName]: digest } } : {}),
|
|
72
146
|
downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
|
|
73
|
-
});
|
|
74
|
-
|
|
147
|
+
}));
|
|
148
|
+
// READ THE BYTES ONCE, and never resolve that path again. Everything after
|
|
149
|
+
// this line acts on a Buffer in amicus's own heap.
|
|
150
|
+
const held = readArtifactBytes({ zip, fs });
|
|
151
|
+
if (!held.bytes) {
|
|
152
|
+
return { pinned: false, refused: refuseUnreadableArtifact({ fileName, zip, why: held.why, detail: held.detail, log }) };
|
|
153
|
+
}
|
|
154
|
+
const gate = verifyArtifactBytes({ bytes: held.bytes, anchor, fileName, policy, log });
|
|
155
|
+
if (!gate.allowed) { return { pinned: false, refused: rejectDownloadedZip({ gate, fileName, log }) }; }
|
|
156
|
+
// C2, on BOTH routes — F3 is the standing reminder of what a rule wired to one
|
|
157
|
+
// provision route and not the other costs. See ./electron-native-rescue for the
|
|
158
|
+
// trigger boundary; `rescue.used` is folded into `pinned` below.
|
|
159
|
+
const rescue = {};
|
|
160
|
+
const extractOrRescue = withNativeRescue({ extract, gate, policy, rescue, platform, fs, spawn, log });
|
|
161
|
+
await extractBytesToDist({ bytes: held.bytes, electronDir, platform, extract: extractOrRescue, fs });
|
|
162
|
+
// BOTH halves, deliberately. `digest` says a `checksums` table went out, so a
|
|
163
|
+
// hatch-dropped pin still reports unverified even when the anchor happens to
|
|
164
|
+
// agree; `verdict === 'verified'` says amicus itself hashed these exact bytes
|
|
165
|
+
// and they matched. Either half alone has been wrong: F3's first cut reported
|
|
166
|
+
// the table, and the table alone is what seat F#2 showed does not describe the
|
|
167
|
+
// bytes that reach the extractor.
|
|
168
|
+
//
|
|
169
|
+
// `!rescue.used` IS IMPLIED TODAY, AND IS STATED ANYWAY — said out loud so it
|
|
170
|
+
// is not mistaken for a measured control. A rescue requires the hatch, and the
|
|
171
|
+
// hatch has already set `digest` to null above, so `pinned` is false on every
|
|
172
|
+
// hatch-on provision whether or not a rescue ran: on THIS route the term
|
|
173
|
+
// changes no observable result. It is here because the property it encodes —
|
|
174
|
+
// a tree a child process extracted from a path was never pinned — must not
|
|
175
|
+
// depend on that coupling holding. On the CACHE route the same term is
|
|
176
|
+
// load-bearing and observable (electron-repair-cache.js :: repairFromCache).
|
|
177
|
+
return { pinned: !!digest && gate.verdict === 'verified' && !rescue.used };
|
|
75
178
|
}
|
|
76
179
|
|
|
77
180
|
/**
|
|
@@ -93,87 +196,37 @@ function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
|
|
|
93
196
|
}
|
|
94
197
|
|
|
95
198
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*/
|
|
102
|
-
function rejectCachedZip({ gate, zip, fileName, env = process.env, fs, log = () => {} }) {
|
|
103
|
-
let removed = false;
|
|
104
|
-
if (gate.verdict === 'mismatch' && mayDeleteRejectedZip({ zip, fileName, env })) {
|
|
105
|
-
try {
|
|
106
|
-
fs.rmSync(zip, { force: true });
|
|
107
|
-
removed = true;
|
|
108
|
-
} catch { /* a cache we cannot write is not a reason to fail the repair */ }
|
|
109
|
-
}
|
|
110
|
-
const what = gate.verdict === 'mismatch'
|
|
111
|
-
? `sha256 ${gate.actual} does not match the published ${gate.expected}`
|
|
112
|
-
: gate.reason;
|
|
113
|
-
log(`[amicus] Electron artifact REFUSED: ${fileName}`);
|
|
114
|
-
log(`[amicus] ${zip}`);
|
|
115
|
-
log(`[amicus] ${what}`);
|
|
116
|
-
log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
|
|
117
|
-
log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
|
|
118
|
-
log('[amicus] electron looks like — amicus cannot tell them apart.');
|
|
119
|
-
// ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
|
|
120
|
-
// do about a file that is already gone: a hand-seeded air-gapped cache is the
|
|
121
|
-
// one place the refused artifact was also the ONLY copy, and being told about
|
|
122
|
-
// the hatch after "The file has been removed." is being told too late to use
|
|
123
|
-
// it. The delete itself is required (a poisoned zip must not survive to be
|
|
124
|
-
// re-offered); the words around it are what make it recoverable.
|
|
125
|
-
log('[amicus] If you deliberately run a REBUILT electron, set');
|
|
126
|
-
log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
|
|
127
|
-
log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
|
|
128
|
-
log(`[amicus] ${removed
|
|
129
|
-
? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
|
|
130
|
-
+ '\n[amicus] amicus download it again) once that variable is set.'
|
|
131
|
-
: 'The file was left in place.'}`);
|
|
132
|
-
log('[amicus] Headless runs and the council work without the GUI.');
|
|
133
|
-
return {
|
|
134
|
-
repaired: false,
|
|
135
|
-
integrity: gate.verdict,
|
|
136
|
-
reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
|
|
137
|
-
+ `${removed ? ' It has been removed.' : ' It was left in place.'}`,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
|
|
142
|
-
function isUnsafeArchive(err) {
|
|
143
|
-
return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
|
|
148
|
-
* as terminal so the same archive is never handed to an OS extractor that has no
|
|
149
|
-
* such check. That invariant held only INSIDE unzip.js: both of repairElectron's
|
|
150
|
-
* catch blocks used to swallow the refusal without reading `err.code` and launder
|
|
151
|
-
* it back into exactly the retry the control forbids — the network path spawned
|
|
152
|
-
* `node <electronDir>/install.js`, which re-downloads and re-extracts through
|
|
153
|
-
* @electron-internal/extract-zip with no amicus supervision (the forbidden move,
|
|
154
|
-
* one stack frame up), and the cache path deleted the zip through the UNFENCED
|
|
155
|
-
* `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
|
|
156
|
-
* reported as corruption. MEASURED both, before this change.
|
|
199
|
+
* THERE IS NO LAST-RESORT INSTALLER ANY MORE (council seat B1, BLOCKER,
|
|
200
|
+
* confirmed 4 of 4). `runInstaller` spawned `<electronDir>/install.js`, which
|
|
201
|
+
* did its OWN download and its OWN extraction, outside every control on this
|
|
202
|
+
* page — and it was reached from `catch (provisionErr)` for ANY throw, so
|
|
203
|
+
* inducing one failure was enough to route around the whole gate.
|
|
157
204
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* `
|
|
161
|
-
*
|
|
205
|
+
* It was worse than "a bypass". install.js pins with
|
|
206
|
+
* `require('./checksums.json')` — the checksums of `<electronDir>`, the very
|
|
207
|
+
* directory `doctor --fix` located by SCANNING npx caches. That is the
|
|
208
|
+
* ANCHORFROMTARGET hole `resolveAnchor`'s rung 1 exists to close, and
|
|
209
|
+
* electron-trust.js records it as MEASURED-exploitable. It also extracted
|
|
210
|
+
* unbounded, with no stall protection and no path-traversal classification, and
|
|
211
|
+
* its success came back as a plain `{repaired: isElectronUsable()}` — bytes
|
|
212
|
+
* amicus never saw, labelled exactly like bytes it hashed.
|
|
213
|
+
*
|
|
214
|
+
* ITS ONE CLAIMED JUSTIFICATION WAS MEASURED FALSE. The case for keeping it was
|
|
215
|
+
* "amicus's own tree cannot resolve @electron/get but electron's can". On this
|
|
216
|
+
* machine, both resolve the SAME hoisted copy:
|
|
217
|
+
* amicus require.resolve('@electron/get') -> node_modules/@electron/get/dist/index.js
|
|
218
|
+
* electron createRequire(electron/package.json).resolve(...) -> node_modules/@electron/get/dist/index.js
|
|
219
|
+
* Every other trigger — a network failure, a mirror checksum failure, an abort
|
|
220
|
+
* timeout, an extract failure — makes install.js do the same download the same
|
|
221
|
+
* way with weaker checks. That is not a fallback; it is a bypass with a retry's
|
|
222
|
+
* reputation. A failed provision is now reported honestly instead:
|
|
223
|
+
* `ensureElectron` already turns that into "the GUI is unavailable, headless
|
|
224
|
+
* runs and the council work", clears its single-flight guard so the next launch
|
|
225
|
+
* retries, and points at `doctor --fix`.
|
|
226
|
+
*
|
|
227
|
+
* `scrubbedChildEnv` died with it (there is no child to build an env for). The
|
|
228
|
+
* enumeration it encoded survives as `isRepoPlantedName` in electron-env-scrub,
|
|
229
|
+
* which is what the IN-PROCESS download scrub uses.
|
|
162
230
|
*/
|
|
163
|
-
function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
|
|
164
|
-
const detail = (err && err.message) || 'the archive tried to write outside its destination';
|
|
165
|
-
log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
|
|
166
|
-
log(`[amicus] ${detail}`);
|
|
167
|
-
log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
|
|
168
|
-
log('[amicus] will not retry it with another extractor, and has left the file in place.');
|
|
169
|
-
log('[amicus] Headless runs and the council work without the GUI.');
|
|
170
|
-
return {
|
|
171
|
-
repaired: false,
|
|
172
|
-
integrity: 'unsafe-archive',
|
|
173
|
-
reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
231
|
|
|
177
|
-
module.exports = {
|
|
178
|
-
cacheRootFor, controlledProvision, mayDeleteRejectedZip, rejectCachedZip, isUnsafeArchive, refuseUnsafeArchive,
|
|
179
|
-
};
|
|
232
|
+
module.exports = { cacheRootFor, controlledProvision, mayDeleteRejectedZip };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron artifact REFUSALS — the ways amicus declines to turn bytes into an
|
|
3
|
+
* Electron install, and the exact words it uses each time. The two NOTICES the
|
|
4
|
+
* native-extractor rescue speaks are the same kind of thing and were briefly
|
|
5
|
+
* here; they live in `./electron-rescue-notice` because this file sits at the
|
|
6
|
+
* repo's 300-line gate and they pushed it over. Same seam either side of the
|
|
7
|
+
* split: the words live in these two files, the policy does not.
|
|
8
|
+
*
|
|
9
|
+
* SPLIT OUT of electron-provision.js (v4.9.6, second council round) because that
|
|
10
|
+
* file sits under the repo's 300-line gate and the round added two more
|
|
11
|
+
* refusals: a DOWNLOADED artifact that fails amicus's own hash (F#2), and an
|
|
12
|
+
* artifact that could not be taken private at all (F#6/F#8). The split is along
|
|
13
|
+
* a real seam — everything here composes a message and returns a result shape,
|
|
14
|
+
* and nothing here decides policy. The one `rmSync` in the file is fenced by an
|
|
15
|
+
* answer its caller computed (`mayDelete`), never by anything derived here.
|
|
16
|
+
*
|
|
17
|
+
* F5 (council seat B5). Everything below that reaches stderr or a returned
|
|
18
|
+
* `reason` goes through the house sanitizer first, because two of its inputs are
|
|
19
|
+
* written by the attacker: an unsafe archive's refusal text carries the ARCHIVE'S
|
|
20
|
+
* OWN entry name, and a cached artifact's path carries a `<sha>` directory name
|
|
21
|
+
* read out of a cache root anyone can write. Unsanitized, either could embed ANSI
|
|
22
|
+
* escapes, a newline plus a forged `[amicus] …` line, or a right-to-left override
|
|
23
|
+
* that renders the rest of the sentence backwards — in the one message a user
|
|
24
|
+
* reads when amicus is telling them something is wrong.
|
|
25
|
+
*
|
|
26
|
+
* NEAR-LEAF MODULE: `utils/text-sanitize` only. electron-install.js and
|
|
27
|
+
* electron-provision.js both require it, which adds no cycle.
|
|
28
|
+
*
|
|
29
|
+
* @module sidecar/electron-refuse
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A path gets a longer cap than an error excerpt: 200 characters truncates a real
|
|
38
|
+
* npx-cache path, and a path the user cannot copy is not much use in a refusal.
|
|
39
|
+
*/
|
|
40
|
+
const PATH_EXCERPT_CHARS = 320;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What each of `readArtifactBytes`'s refusals MEANS, in one clause the user can
|
|
44
|
+
* act on. Kept here rather than in the custody module so every user-facing
|
|
45
|
+
* sentence in this subsystem is written in one file.
|
|
46
|
+
*/
|
|
47
|
+
const UNREADABLE_REASON = {
|
|
48
|
+
unreadable: 'could not be opened or read at all',
|
|
49
|
+
'not-a-file': 'is not a regular file',
|
|
50
|
+
empty: 'is empty',
|
|
51
|
+
'too-large': 'is far larger than any electron artifact',
|
|
52
|
+
'short-read': 'ended early while amicus was reading it',
|
|
53
|
+
grew: 'changed size while amicus was reading it',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
|
|
57
|
+
function isUnsafeArchive(err) {
|
|
58
|
+
return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
|
|
63
|
+
* as terminal so the same archive is never handed to an OS extractor that has no
|
|
64
|
+
* such check. That invariant held only INSIDE unzip.js: both of repairElectron's
|
|
65
|
+
* catch blocks used to swallow the refusal without reading `err.code` and launder
|
|
66
|
+
* it back into exactly the retry the control forbids — the network path spawned
|
|
67
|
+
* `node <electronDir>/install.js`, which re-downloads and re-extracts through
|
|
68
|
+
* @electron-internal/extract-zip with no amicus supervision (the forbidden move,
|
|
69
|
+
* one stack frame up), and the cache path deleted the zip through the UNFENCED
|
|
70
|
+
* `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
|
|
71
|
+
* reported as corruption. MEASURED both, before this change.
|
|
72
|
+
*
|
|
73
|
+
* So the refusal ends here: no retry, no fallback extractor, and no delete. The
|
|
74
|
+
* archive is left where it is, because a refused archive is evidence, and
|
|
75
|
+
* `err.message` already carries the path and extract-zip's own reason.
|
|
76
|
+
* @returns {{repaired:false, integrity:'unsafe-archive', reason:string}}
|
|
77
|
+
*/
|
|
78
|
+
function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
|
|
79
|
+
// F5: this message quotes the ARCHIVE'S OWN entry name back at the user. The
|
|
80
|
+
// house sanitizer runs before it reaches stderr or the returned reason.
|
|
81
|
+
const detail = collapseExcerpt((err && err.message) || 'the archive tried to write outside its destination');
|
|
82
|
+
log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
|
|
83
|
+
log(`[amicus] ${detail}`);
|
|
84
|
+
log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
|
|
85
|
+
log('[amicus] will not retry it with another extractor, and has left the file in place.');
|
|
86
|
+
log('[amicus] Headless runs and the council work without the GUI.');
|
|
87
|
+
return {
|
|
88
|
+
repaired: false,
|
|
89
|
+
integrity: 'unsafe-archive',
|
|
90
|
+
reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Act on a REFUSED cached artifact: remove the poison when it is safe to, say
|
|
96
|
+
* plainly what happened, and hand back the result shape a cacheOnly caller
|
|
97
|
+
* returns. Deletion happens ONLY on `mismatch` — a `no-digest` artifact is not
|
|
98
|
+
* evidence of anything, and an `unreadable` one is a file we could not even hash.
|
|
99
|
+
*
|
|
100
|
+
* `zip` is the ORIGINAL cache path, which is also the ONLY copy of these bytes
|
|
101
|
+
* anyone can reach: staging COPIES, so the artifact never left the cache and
|
|
102
|
+
* this `rmSync` is the whole delete, exactly as it was in v4.9.5. (The first cut
|
|
103
|
+
* of the F1 work made it conditional on a `moved` flag the stage object carried;
|
|
104
|
+
* seats F#4/F#5 caught the sibling branch where that silently removed nothing.)
|
|
105
|
+
*
|
|
106
|
+
* `mayDelete` IS THE FENCE'S ANSWER, PASSED IN, not re-derived here — the fence
|
|
107
|
+
* realpaths the artifact, so it has to be asked while the file is still where the
|
|
108
|
+
* caller found it.
|
|
109
|
+
* @returns {{repaired:false, integrity:string, reason:string}}
|
|
110
|
+
*/
|
|
111
|
+
function rejectCachedZip({ gate, zip, fileName, mayDelete = false, fs, log = () => {} }) {
|
|
112
|
+
let removed = false;
|
|
113
|
+
if (gate.verdict === 'mismatch' && mayDelete) {
|
|
114
|
+
try {
|
|
115
|
+
fs.rmSync(zip, { force: true });
|
|
116
|
+
removed = true;
|
|
117
|
+
} catch { /* a cache we cannot write is not a reason to fail the repair */ }
|
|
118
|
+
}
|
|
119
|
+
// F5: `gate.reason` is an fs error string (an `unreadable` verdict) and `zip` is
|
|
120
|
+
// an attacker-influenced cache path; the digests are 64-hex by construction.
|
|
121
|
+
const what = gate.verdict === 'mismatch'
|
|
122
|
+
? `sha256 ${gate.actual} does not match the published ${gate.expected}`
|
|
123
|
+
: collapseExcerpt(gate.reason);
|
|
124
|
+
log(`[amicus] Electron artifact REFUSED: ${fileName}`);
|
|
125
|
+
log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
|
|
126
|
+
log(`[amicus] ${what}`);
|
|
127
|
+
log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
|
|
128
|
+
log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
|
|
129
|
+
log('[amicus] electron looks like — amicus cannot tell them apart.');
|
|
130
|
+
// ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
|
|
131
|
+
// do about a file that is already gone: a hand-seeded air-gapped cache is the
|
|
132
|
+
// one place the refused artifact was also the ONLY copy, and being told about
|
|
133
|
+
// the hatch after "The file has been removed." is being told too late to use
|
|
134
|
+
// it. The delete itself is required (a poisoned zip must not survive to be
|
|
135
|
+
// re-offered); the words around it are what make it recoverable.
|
|
136
|
+
log('[amicus] If you deliberately run a REBUILT electron, set');
|
|
137
|
+
log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
|
|
138
|
+
log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
|
|
139
|
+
log(`[amicus] ${removed
|
|
140
|
+
? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
|
|
141
|
+
+ '\n[amicus] amicus download it again) once that variable is set.'
|
|
142
|
+
: 'The file was left in place.'}`);
|
|
143
|
+
log('[amicus] Headless runs and the council work without the GUI.');
|
|
144
|
+
return {
|
|
145
|
+
repaired: false,
|
|
146
|
+
integrity: gate.verdict,
|
|
147
|
+
reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
|
|
148
|
+
+ `${removed ? ' It has been removed.' : ' It was left in place.'}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* F#2 — a DOWNLOADED artifact that amicus hashed for itself and refused.
|
|
154
|
+
*
|
|
155
|
+
* WHY THERE IS A SECOND GATE AT ALL, when `checksums` already went out.
|
|
156
|
+
* @electron/get validates in its own temp dir and then RENAMES the artifact into
|
|
157
|
+
* the cache root, returning THAT path (5.0.0, dist/index.js lines 148-160 and
|
|
158
|
+
* Cache.js lines 60-76) — so the last bytes it vouched for live at a path the
|
|
159
|
+
* same cache-dir writer controls. On a cache HIT it is looser still: `force` is
|
|
160
|
+
* dead in 5.0.0 (the only read guard is `shouldTryReadCache(effectiveCacheMode(...))`,
|
|
161
|
+
* and amicus sends no `cacheMode`), so it validates a file AT ITS CACHE PATH and
|
|
162
|
+
* hands that back. Either way the vouching happened somewhere amicus does not
|
|
163
|
+
* control, which is the whole reason the bytes are read into memory and
|
|
164
|
+
* re-hashed here. (They are not COPIED anywhere: the private staging directory
|
|
165
|
+
* that used to hold them was deleted in this change, its privacy claim having
|
|
166
|
+
* been measured false.)
|
|
167
|
+
*
|
|
168
|
+
* NOTHING IS DELETED. The refused artifact stays where @electron/get put it, and
|
|
169
|
+
* the CACHE route removes it on the next run through the fence that was written
|
|
170
|
+
* for exactly that (`mayDeleteRejectedZip` + `rejectCachedZip`). Deleting from
|
|
171
|
+
* here would add a second, differently-fenced unlink for no gain: the number of
|
|
172
|
+
* downloads is the same either way.
|
|
173
|
+
* @returns {{repaired:false, integrity:string, reason:string}}
|
|
174
|
+
*/
|
|
175
|
+
function rejectDownloadedZip({ gate, fileName, log = () => {} }) {
|
|
176
|
+
const what = gate.verdict === 'mismatch'
|
|
177
|
+
? `sha256 ${gate.actual} does not match the published ${gate.expected}`
|
|
178
|
+
: collapseExcerpt(gate.reason);
|
|
179
|
+
log(`[amicus] DOWNLOADED electron artifact REFUSED: ${fileName}`);
|
|
180
|
+
log(`[amicus] ${what}`);
|
|
181
|
+
// WHAT THIS LINE MAY CLAIM. It used to promise the download was hashed inside
|
|
182
|
+
// a PRIVATE staging directory — a containment property that was DELETED in
|
|
183
|
+
// this same change, because the council measured it false (mkdtempSync yields
|
|
184
|
+
// mode 666 on Windows and the chmod(0o700) was skipped on win32, and a spinner
|
|
185
|
+
// found the fixed `amicus-electron-stage-` prefix on its FIRST readdir). The
|
|
186
|
+
// docs describing that copy were fixed and this string was missed; nothing
|
|
187
|
+
// pinned it, so nothing caught it. It now states the property that is
|
|
188
|
+
// actually true, and it is pinned by a test.
|
|
189
|
+
log('[amicus] amicus read these bytes ONCE, into its own memory, and hashed THAT buffer — the');
|
|
190
|
+
log('[amicus] same buffer it would have extracted. There is no copy on disk and no path in play');
|
|
191
|
+
log('[amicus] after the read. These bytes were NOT extracted and no Electron was installed from');
|
|
192
|
+
log('[amicus] them. Check what ELECTRON_MIRROR points at.');
|
|
193
|
+
log('[amicus] Headless runs and the council work without the GUI.');
|
|
194
|
+
return {
|
|
195
|
+
repaired: false,
|
|
196
|
+
integrity: gate.verdict,
|
|
197
|
+
reason: `Downloaded electron artifact ${fileName} was REFUSED: ${what}. It was NOT extracted.`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bytes amicus could not take into custody are never extracted.
|
|
203
|
+
*
|
|
204
|
+
* REPLACES `refuseUnstagedArtifact`, and the replacement is not cosmetic. The
|
|
205
|
+
* old refusal existed because staging COPIED the artifact into `os.tmpdir()`,
|
|
206
|
+
* so its whole vocabulary was about temp: "could not be copied into a private
|
|
207
|
+
* directory", "free up space in the temp directory". Council finding A3 caught
|
|
208
|
+
* the consequence — a cache entry deleted by a concurrent repair mid-copy was
|
|
209
|
+
* reported as "no space, or an unwritable temp directory", advice that could
|
|
210
|
+
* not possibly help. There is no copy and no temp directory any more; the only
|
|
211
|
+
* way to fail here is that the ARTIFACT ITSELF could not be read, and `why`
|
|
212
|
+
* says which way.
|
|
213
|
+
*
|
|
214
|
+
* The first cut of the staging work FAILED OPEN in this position: when the
|
|
215
|
+
* artifact could not be taken private the download route quietly extracted the
|
|
216
|
+
* unstaged cache path anyway, while the one line on screen said the bytes would
|
|
217
|
+
* not be extracted (seats F#6/F#8, MEASURED both shapes). Both routes refuse
|
|
218
|
+
* here now, and `integrity` is set so `scripts/postinstall.js` prints the reason
|
|
219
|
+
* instead of its generic "provisions on first use" notice.
|
|
220
|
+
*
|
|
221
|
+
* F5: `zip` is an attacker-influenced cache path (its `<sha>` directory name
|
|
222
|
+
* came out of a `readdirSync` of a directory anyone can write) and `detail` is
|
|
223
|
+
* an fs error string, so both are sanitized before reaching stderr or the
|
|
224
|
+
* returned reason.
|
|
225
|
+
* @param {object} o
|
|
226
|
+
* @param {string} o.fileName
|
|
227
|
+
* @param {string} o.zip
|
|
228
|
+
* @param {string} o.why one of readArtifactBytes's named refusals
|
|
229
|
+
* @param {string} [o.detail]
|
|
230
|
+
* @returns {{repaired:false, integrity:'unreadable', reason:string}}
|
|
231
|
+
*/
|
|
232
|
+
function refuseUnreadableArtifact({ fileName, zip, why, detail = '', log = () => {} }) {
|
|
233
|
+
const where = collapseExcerpt(zip, PATH_EXCERPT_CHARS);
|
|
234
|
+
const what = UNREADABLE_REASON[why] || 'could not be read';
|
|
235
|
+
log(`[amicus] Electron artifact NOT extracted: ${fileName}`);
|
|
236
|
+
log(`[amicus] ${where}`);
|
|
237
|
+
log(`[amicus] ${what}${detail ? ` (${collapseExcerpt(detail)})` : ''}`);
|
|
238
|
+
log('[amicus] amicus reads an artifact ONCE, into memory, and hashes and extracts THOSE');
|
|
239
|
+
log('[amicus] bytes. It could not read these, so it has nothing it could vouch for and');
|
|
240
|
+
log('[amicus] has extracted nothing. The file was left exactly where it is.');
|
|
241
|
+
log('[amicus] Headless runs and the council work without the GUI.');
|
|
242
|
+
return {
|
|
243
|
+
repaired: false,
|
|
244
|
+
integrity: 'unreadable',
|
|
245
|
+
reason: `Electron artifact ${fileName} ${what}, so it was NOT extracted: ${where}.`
|
|
246
|
+
+ ' It was left in place.',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
isUnsafeArchive, refuseUnsafeArchive, rejectCachedZip, rejectDownloadedZip, refuseUnreadableArtifact,
|
|
252
|
+
PATH_EXCERPT_CHARS,
|
|
253
|
+
};
|