amicus 4.9.4 → 4.9.6
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 +171 -3
- package/README.md +1 -1
- package/docs/ROADMAP.md +6 -4
- package/docs/architecture-map.md +31 -3
- package/docs/configuration.md +97 -0
- package/docs/troubleshooting.md +130 -0
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +28 -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-install.js +149 -159
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +157 -0
- package/src/sidecar/electron-native-rescue.js +231 -0
- package/src/sidecar/electron-provision.js +232 -0
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +226 -0
- package/src/sidecar/unzip.js +52 -2
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +220 -0
- package/src/sidecar/zip-name-scan.js +141 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NATIVE-EXTRACTOR RESCUE — the one way an archive amicus's own extractor
|
|
3
|
+
* cannot parse still becomes an install, what it costs, and why it is reachable
|
|
4
|
+
* from exactly one place.
|
|
5
|
+
*
|
|
6
|
+
* ── THE FINDING (council run 34182994208, major, deepseek seat) ───────────
|
|
7
|
+
* "Deleting the native-unzip fallback and the last-resort install.js spawn
|
|
8
|
+
* weakens the air-gapped recovery path to a permanent no-rescue failure for
|
|
9
|
+
* archives yauzl cannot parse." True as filed. `zip-stall-bound.js` had already
|
|
10
|
+
* written the loss down honestly — "an archive yauzl cannot parse but a native
|
|
11
|
+
* extractor could is a failed repair plus a re-download" — and on a machine with
|
|
12
|
+
* no network to re-download from, that is the end of the road.
|
|
13
|
+
*
|
|
14
|
+
* ── WHY IT CANNOT SIMPLY BE PUT BACK, WHICH IS THE WHOLE JUSTIFICATION ────
|
|
15
|
+
* Every native extractor — `tar`, `Expand-Archive`, `ditto`, `unzip` — takes a
|
|
16
|
+
* PATH. Using one therefore means writing bytes down and letting a child process
|
|
17
|
+
* open them: the SECOND PATH RESOLUTION the custody design exists to eliminate
|
|
18
|
+
* (`electron-custody.js` carries the three refuted remedies and the measurements
|
|
19
|
+
* that killed them). The custody property — **amicus never itself writes, or
|
|
20
|
+
* reports as verified, bytes it did not hash** — and a path-taking extractor
|
|
21
|
+
* cannot both hold on the same run. There is no clever version of this: the two
|
|
22
|
+
* properties are in direct contradiction.
|
|
23
|
+
*
|
|
24
|
+
* That trade is acceptable ONLY under a flag whose existing documented meaning is
|
|
25
|
+
* already "I accept Electron bytes amicus cannot vouch for", and it must not be
|
|
26
|
+
* reachable any other way. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` is that flag, and
|
|
27
|
+
* it is read in exactly one place (`electron-trust.js :: electronTrustPolicy`),
|
|
28
|
+
* from a bare environment name a repository's `.npmrc` cannot plant. Custody
|
|
29
|
+
* stays absolute by default. `runInstaller` is NOT coming back — it did its own
|
|
30
|
+
* download with its own anchor, which is a different and much worse thing (see
|
|
31
|
+
* `electron-provision.js`).
|
|
32
|
+
*
|
|
33
|
+
* ── THE TRIGGER BOUNDARY, WHICH MATTERS MORE THAN THE MECHANISM ───────────
|
|
34
|
+
* "Fall back when extraction fails" would re-arm the laundering bug this cluster
|
|
35
|
+
* already fixed once (C4): a security refusal handed to a tool with no such
|
|
36
|
+
* check. So the trigger is ONE extractor verdict, `UNZIP_BUFFER_FAILED` — the
|
|
37
|
+
* code whose defined meaning is "the ARCHIVE is bad", and already the only
|
|
38
|
+
* failure that licences an eviction (`electron-repair-cache.js ::
|
|
39
|
+
* EVICTS_THE_ARTIFACT`). The rescue and the eviction now share one trigger.
|
|
40
|
+
*
|
|
41
|
+
* YES the extractor positively identified the ARCHIVE as unreadable.
|
|
42
|
+
* NO `UNZIP_UNSAFE_ARCHIVE` — a path-traversal REFUSAL. Terminal by design;
|
|
43
|
+
* C4 exists precisely to stop a refusal being retried through a tool
|
|
44
|
+
* amicus does not control, and the hatch is not even MENTIONED on that
|
|
45
|
+
* exit — advertising it there would be the same laundering with a human
|
|
46
|
+
* in the loop.
|
|
47
|
+
* NO `UNZIP_BUFFER_STALLED` — the bound exists to STOP work, not to hand it
|
|
48
|
+
* to someone else, and a stall is a verdict about nothing.
|
|
49
|
+
* NO a digest MISMATCH. The bytes are known wrong; there is nothing to
|
|
50
|
+
* rescue. This is the exclusion that is easy to get wrong, because the
|
|
51
|
+
* same flag that opens the rescue ALSO downgrades a mismatch refusal to a
|
|
52
|
+
* warning (`electron-trust.js :: verifyArtifactBytes`) — so with the hatch
|
|
53
|
+
* set, contradicted bytes really do reach the extractor. `gate.verdict`
|
|
54
|
+
* is checked here for that one reason.
|
|
55
|
+
* NO `UNZIP_DEST_FAILED` — not the archive's fault, and a native extractor
|
|
56
|
+
* writing to the same full or unwritable destination fails identically.
|
|
57
|
+
* NO `UNZIP_BUFFER_UNAVAILABLE`, and NO an error carrying no `code` at all.
|
|
58
|
+
* Neither is a parse failure, and a rule that fails OPEN on the shapes
|
|
59
|
+
* nobody enumerated is the shape D2 was filed against. Fail closed.
|
|
60
|
+
*
|
|
61
|
+
* AND A VERDICT IS NOT THE WHOLE QUESTION. Every line above keys on the refusal
|
|
62
|
+
* yauzl FORMED, and yauzl checks an entry's size before its name — so an archive
|
|
63
|
+
* whose first entry breaks the extraction arrives as `UNZIP_BUFFER_FAILED` with
|
|
64
|
+
* its traversal entry never looked at. One flag bit, MEASURED, moves the same
|
|
65
|
+
* archive from the terminal class into the rescuable one. So the boundary also
|
|
66
|
+
* asks what names the archive DECLARES (`hostileName`), and a name yauzl would
|
|
67
|
+
* have refused is treated as the refusal it would have raised. What that scan
|
|
68
|
+
* cannot see is written down there rather than papered over.
|
|
69
|
+
*
|
|
70
|
+
* ── AND `no-digest` IS ALLOWED, DELIBERATELY ──────────────────────────────
|
|
71
|
+
* Only `mismatch` is excluded. `no-digest` means nobody ever published a digest
|
|
72
|
+
* for this artifact — an Electron package predating `checksums.json` — which is
|
|
73
|
+
* exactly the old, air-gapped machine the finding is about. Nothing contradicts
|
|
74
|
+
* those bytes; refusing them would delete the rescue for its main case.
|
|
75
|
+
*
|
|
76
|
+
* ── WHAT A RESCUE CAN NEVER DO: REPORT CLEAN ──────────────────────────────
|
|
77
|
+
* `rescue.used` is set when the rescue runs, and both routes fold it into their
|
|
78
|
+
* `unverified` mark, so a rescued install is never reported as verified even
|
|
79
|
+
* when the artifact's own sha256 matched. It has to be: the bytes in `dist/` were
|
|
80
|
+
* placed there by a child process reading a path, not by amicus writing what it
|
|
81
|
+
* hashed. Reporting that as verified is the exact overclaim the custody property
|
|
82
|
+
* forbids.
|
|
83
|
+
*
|
|
84
|
+
* ── OFFERED-BUT-UNARMED: THE OFFER IS A PROMISE ──────────────────────────
|
|
85
|
+
* With the hatch OFF, a parse failure prints an offer naming the flag and telling
|
|
86
|
+
* an air-gapped user to set it and provision again. The FIRST cut of C2 printed
|
|
87
|
+
* that and then let the same `UNZIP_BUFFER_FAILED` reach the cache route's
|
|
88
|
+
* eviction, which DELETED the artifact on the way out — MEASURED end to end: the
|
|
89
|
+
* ten-line offer on stderr, `reason: "… was corrupt and removed"`, the zip gone,
|
|
90
|
+
* and the re-run the message asks for ending in `No cached electron zip found`.
|
|
91
|
+
* On the machine the whole finding is about, the offer named a rescue that its
|
|
92
|
+
* own run had just made impossible.
|
|
93
|
+
*
|
|
94
|
+
* So `rescue.offered` is set HERE, where the promise is made, and
|
|
95
|
+
* `electron-repair-cache.js` keeps the artifact when it is set. The cost is the
|
|
96
|
+
* availability cost that module already accepts wherever its delete fence says
|
|
97
|
+
* no: an unreadable zip survives and is re-downloaded once per provision. That
|
|
98
|
+
* is the right way round — a copy nobody can read costs one download; a copy
|
|
99
|
+
* that is gone costs the only rescue there was.
|
|
100
|
+
*
|
|
101
|
+
* @module sidecar/electron-native-rescue
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
'use strict';
|
|
105
|
+
|
|
106
|
+
const { spawnSync } = require('child_process');
|
|
107
|
+
|
|
108
|
+
// The cap comes from `unzip.js`, which is byte-for-byte unchanged: this is a
|
|
109
|
+
// re-wiring of a caller, not a change to that module.
|
|
110
|
+
const { MAX_MS } = require('./unzip');
|
|
111
|
+
// The mechanism this file decides about: writing the buffer down, walking the
|
|
112
|
+
// platform's plan, sweeping up. `RESCUE_ZIP` and `INCOMING_PREFIX` are re-exported
|
|
113
|
+
// below because they name what a rescue leaves on disk, which is this module's
|
|
114
|
+
// subject even though the code that writes them is next door.
|
|
115
|
+
const { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX } = require('./electron-native-plan');
|
|
116
|
+
// The offer a parse failure gets when the hatch is off. Every other user-facing
|
|
117
|
+
// sentence in this subsystem is written in the same two files.
|
|
118
|
+
const { offerNativeRescue } = require('./electron-rescue-notice');
|
|
119
|
+
// The read-only name walk the boundary consults before it trusts a verdict.
|
|
120
|
+
const { scanEntryNames } = require('./zip-name-scan');
|
|
121
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
122
|
+
|
|
123
|
+
/** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
|
|
124
|
+
const RESCUE_TRIGGER = 'UNZIP_BUFFER_FAILED';
|
|
125
|
+
|
|
126
|
+
/** True for the ONE failure class the owner authorised a rescue for. */
|
|
127
|
+
function isRescuableFailure(err) {
|
|
128
|
+
return !!err && err.code === RESCUE_TRIGGER;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A parse failure's archive, asked what ENTRY NAMES it declares.
|
|
133
|
+
*
|
|
134
|
+
* THE EXCLUSION ABOVE KEYS ON A REFUSAL, AND A REFUSAL HAS TO BE FORMED. yauzl
|
|
135
|
+
* checks an entry's size before its name, so an archive whose FIRST entry breaks
|
|
136
|
+
* the extraction never reaches the traversal entry behind it and arrives here as
|
|
137
|
+
* `UNZIP_BUFFER_FAILED` — the one class a rescue acts on. MEASURED: one flag bit
|
|
138
|
+
* moves the same archive from `UNZIP_UNSAFE_ARCHIVE` into the rescue, traversal
|
|
139
|
+
* entry and all (`zip-name-scan.js` carries both measurements and the two
|
|
140
|
+
* Windows tools' own refusals, which are what stopped the escape that run).
|
|
141
|
+
*
|
|
142
|
+
* So the boundary asks about the ENTRIES, not only the verdict, and a name yauzl
|
|
143
|
+
* would have refused becomes the refusal yauzl would have raised: terminal,
|
|
144
|
+
* unadvertised, left in place, exactly as if the archive had had nothing wrong
|
|
145
|
+
* with it but that entry.
|
|
146
|
+
*
|
|
147
|
+
* THE RESIDUAL, STATED RATHER THAN ENGINEERED AWAY. The scan reads the central
|
|
148
|
+
* directory; an archive whose central directory is unreadable — a truncated zip,
|
|
149
|
+
* the commonest thing this rescue exists for — declares no names it can see, and
|
|
150
|
+
* that archive still reaches the native extractor. Nothing here covers a SYMLINK
|
|
151
|
+
* whose target escapes either: that is a payload, not a name. In both cases the
|
|
152
|
+
* only remaining check is the extractor's own, which `tar` and `Expand-Archive`
|
|
153
|
+
* were MEASURED to have (`ditto` and Info-ZIP `unzip` are unmeasured), and
|
|
154
|
+
* `cleanDir` sweeps only inside `dir` — anything a native tool wrote outside it
|
|
155
|
+
* would survive a failed strategy. `docs/configuration.md` says the same thing to
|
|
156
|
+
* the user who has to decide whether to set the flag.
|
|
157
|
+
* @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
|
|
158
|
+
*/
|
|
159
|
+
async function hostileName(bytes, log) {
|
|
160
|
+
const seen = await scanEntryNames(bytes);
|
|
161
|
+
if (!seen.refusal) { return null; }
|
|
162
|
+
log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
|
|
163
|
+
log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
|
|
164
|
+
log(`[amicus] ${collapseExcerpt(seen.refusal)}`);
|
|
165
|
+
log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
|
|
166
|
+
return Object.assign(
|
|
167
|
+
new Error(`refusing to extract this archive: ${collapseExcerpt(seen.refusal)}`),
|
|
168
|
+
{ code: 'UNZIP_UNSAFE_ARCHIVE' },
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Wrap a buffer extractor so a PARSE FAILURE — and nothing else — may be rescued
|
|
174
|
+
* by the native plan when the hatch is set.
|
|
175
|
+
*
|
|
176
|
+
* ONE WRAPPER, WIRED AT BOTH CALL SITES. `gate` and `policy` are only known after
|
|
177
|
+
* the digest gate has run, which is why this is composed inside each route rather
|
|
178
|
+
* than once in `repairElectron`; F3 is the standing reminder of what happens when
|
|
179
|
+
* a rule lands on one provision route and not the other, so a test asserts both.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} o
|
|
182
|
+
* @param {function} o.extract the buffer extractor being wrapped
|
|
183
|
+
* @param {object} o.gate verifyArtifactBytes's result (its `verdict` is read)
|
|
184
|
+
* @param {object} o.policy electronTrustPolicy's result (the hatch)
|
|
185
|
+
* @param {object} o.rescue OUT: `{used, strategy}` when a rescue ran, and
|
|
186
|
+
* `{offered:true}` when one was named but not armed — the caller must not then
|
|
187
|
+
* discard the artifact the offer points at
|
|
188
|
+
* @returns {function} an extractor with the same (bytes, {dir}) signature
|
|
189
|
+
*/
|
|
190
|
+
function withNativeRescue({
|
|
191
|
+
extract, gate = {}, policy = {}, rescue = {}, platform = process.platform,
|
|
192
|
+
fs, spawn = spawnSync, maxMs = MAX_MS, log = () => {},
|
|
193
|
+
}) {
|
|
194
|
+
return async (bytes, o) => {
|
|
195
|
+
try {
|
|
196
|
+
return await extract(bytes, o);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// Every class but one leaves through here untouched and unadvertised.
|
|
199
|
+
if (!isRescuableFailure(err)) { throw err; }
|
|
200
|
+
// ...and the one class that IS rescuable is asked what names it declares
|
|
201
|
+
// first, because the exclusion above keys on the refusal yauzl FORMED and
|
|
202
|
+
// an earlier bad entry stops it forming one. See `hostileName`.
|
|
203
|
+
const hostile = await hostileName(bytes, log);
|
|
204
|
+
if (hostile) { throw hostile; }
|
|
205
|
+
if (!policy.allowUnverified) {
|
|
206
|
+
// `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
|
|
207
|
+
// honour it: see the docblock's OFFERED-BUT-UNARMED section.
|
|
208
|
+
rescue.offered = true;
|
|
209
|
+
offerNativeRescue({ reason: (err && err.message) || '', log });
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
if (gate.verdict === 'mismatch') {
|
|
213
|
+
log('[amicus] the native-extractor rescue was NOT attempted: these bytes contradict the published sha256, so there is nothing to rescue.');
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
const strategy = nativeRescue({
|
|
217
|
+
bytes, dir: o.dir, reason: (err && err.message) || '', platform, fs, spawn, maxMs, log,
|
|
218
|
+
});
|
|
219
|
+
// A rescue that failed leaves the ORIGINAL classified error in flight, so
|
|
220
|
+
// a genuinely bad archive is still evicted exactly as it was before.
|
|
221
|
+
if (!strategy) { throw err; }
|
|
222
|
+
rescue.used = true;
|
|
223
|
+
rescue.strategy = strategy;
|
|
224
|
+
return { strategy, rescued: true };
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
withNativeRescue, isRescuableFailure, RESCUE_TRIGGER, RESCUE_ZIP, INCOMING_PREFIX,
|
|
231
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron CONTROLLED provision — the pinned download, and the fence that says
|
|
3
|
+
* whether a refused cache artifact may be deleted.
|
|
4
|
+
*
|
|
5
|
+
* Split out of electron-install.js because that file sits at the repo's 300-line
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* @module sidecar/electron-provision
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const { resolveCacheRoots } = require('./electron-cache');
|
|
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');
|
|
28
|
+
const { containsOnDisk } = require('../utils/path-fence');
|
|
29
|
+
|
|
30
|
+
/** Best-effort cache root for downloadArtifact (first resolved root). */
|
|
31
|
+
function cacheRootFor(env = process.env) {
|
|
32
|
+
return resolveCacheRoots(env)[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
|
|
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.
|
|
40
|
+
*
|
|
41
|
+
* C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
|
|
42
|
+
* @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
|
|
43
|
+
* from the mirror, so a redirected download still has to produce bytes matching
|
|
44
|
+
* electron's own published sha256. Passed as the ONE entry for this artifact:
|
|
45
|
+
* an empty table is a hard throw upstream, and a table missing the requested
|
|
46
|
+
* name fails the download outright — which is why no anchor means no `checksums`
|
|
47
|
+
* key at all rather than an empty one. With no anchor, @electron/get falls back
|
|
48
|
+
* to its own remote SHASUMS256.txt fetch: weaker, but never a re-download loop.
|
|
49
|
+
*
|
|
50
|
+
* THE HATCH REACHES THIS ROUTE TOO. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` used to
|
|
51
|
+
* be handed only to the cached-artifact gate, so the one case both docs describe
|
|
52
|
+
* — a machine whose electron bytes legitimately differ (a local rebuild, an
|
|
53
|
+
* internally-signed build on a corporate mirror) — still had its download pinned
|
|
54
|
+
* to the official digest and failed. MEASURED before this change: with the hatch
|
|
55
|
+
* set and nothing in any cache root, `checksums` was still sent and the call
|
|
56
|
+
* returned `{repaired:false}` with no reason at all. When the hatch is set the
|
|
57
|
+
* pin is dropped here and @electron/get falls back to the mirror's own
|
|
58
|
+
* SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
|
|
59
|
+
* so it is stated out loud on stderr every time rather than happening quietly —
|
|
60
|
+
* and it is reachable ONLY through a bare env name a repository cannot plant.
|
|
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.
|
|
91
|
+
*/
|
|
92
|
+
async function controlledProvision({
|
|
93
|
+
electronDir, platform, arch, version, anchor, downloadArtifact, extract,
|
|
94
|
+
fs, spawn, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
|
|
95
|
+
}) {
|
|
96
|
+
const fileName = artifactFileName({ version, platform, arch });
|
|
97
|
+
let digest = expectedDigest(anchor, fileName);
|
|
98
|
+
if (digest && policy.allowUnverified) {
|
|
99
|
+
log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
|
|
100
|
+
log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
|
|
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.');
|
|
107
|
+
}
|
|
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({
|
|
132
|
+
version,
|
|
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.
|
|
141
|
+
force: true,
|
|
142
|
+
cacheRoot: cacheRootFor(env),
|
|
143
|
+
platform,
|
|
144
|
+
arch,
|
|
145
|
+
...(digest ? { checksums: { [fileName]: digest } } : {}),
|
|
146
|
+
downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
|
|
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 };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* May this refused artifact be deleted? STRICTLY NARROWER than the unconditional
|
|
182
|
+
* `fs.rmSync` on the corrupt-extract path: the basename must be exactly the
|
|
183
|
+
* artifact we asked for, AND the file must resolve inside a resolved cache root.
|
|
184
|
+
* `containsOnDisk` realpaths both sides and returns false on any error, so an
|
|
185
|
+
* unresolvable path is refused rather than trusted — deleting at an
|
|
186
|
+
* attacker-influenceable path is the one thing a poisoned cache could otherwise
|
|
187
|
+
* turn into a weapon.
|
|
188
|
+
*
|
|
189
|
+
* Cost if it returns a wrong false: the mismatched zip stays and is re-downloaded
|
|
190
|
+
* once per provision. An availability cost, never a safety one — the gate above
|
|
191
|
+
* still refuses to extract it.
|
|
192
|
+
*/
|
|
193
|
+
function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
|
|
194
|
+
if (path.basename(zip) !== fileName) { return false; }
|
|
195
|
+
return resolveCacheRoots(env).some((root) => containsOnDisk(root, zip));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
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.
|
|
204
|
+
*
|
|
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.
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
module.exports = { cacheRootFor, controlledProvision, mayDeleteRejectedZip };
|