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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * ATTEMPT 1 of the electron self-heal: turn a CACHED artifact into a `dist/`,
3
+ * fully offline, in custody.
4
+ *
5
+ * SPLIT OUT of electron-install.js in the v4.9.6 second council round. That file
6
+ * sits at the repo's 300-line gate and this route grew a step (read into memory)
7
+ * while keeping every branch it already had. The seam is real: everything here
8
+ * answers "can this cache entry become an install", and nothing here decides
9
+ * where the cache is, what the anchor is, or what happens next — those arrive as
10
+ * arguments.
11
+ *
12
+ * THE SHAPE OF THE ROUTE, and what each step now promises:
13
+ * 1. READ the artifact ONCE into a Buffer (`electron-custody`). The path is
14
+ * resolved exactly once, ever. Anything that goes wrong here is a REFUSAL
15
+ * with a named `why`, never a silent fall-through.
16
+ * 2. HASH THAT BUFFER against the anchor (`electron-trust.verifyArtifactBytes`).
17
+ * 3. EXTRACT THAT BUFFER (`electron-layout.extractBytesToDist`), into an
18
+ * incoming directory that is promoted by rename.
19
+ * There is no step in which a name is resolved a second time, which is what
20
+ * makes the claim "the bytes that were hashed are the bytes that were written"
21
+ * true rather than a race the attacker usually loses.
22
+ *
23
+ * WHAT AN EXTRACT FAILURE NOW MEANS (council finding D2). The old code deleted
24
+ * the cached artifact on ANY extract error, including a failure to write
25
+ * `dist/`. A full disk therefore destroyed a pristine cache entry — worst case
26
+ * on an air-gapped run with no network to re-fetch from. `zip-from-buffer`
27
+ * distinguishes the two: `UNZIP_BUFFER_FAILED` says the ARCHIVE is bad and is
28
+ * the only failure that CAN evict anything; `UNZIP_DEST_FAILED` says the
29
+ * DESTINATION is bad and never evicts. Since C2 even that one holds its fire on
30
+ * a run that OFFERED the native-extractor rescue: an offer whose own run
31
+ * destroys the artifact it points at is not an offer.
32
+ *
33
+ * NEAR-LEAF: requires the custody, trust, refusal, layout and provision-fence
34
+ * modules, and is required only by electron-install.js. The arrow is
35
+ * electron-install -> electron-repair-cache -> {electron-custody, electron-trust,
36
+ * electron-refuse, electron-layout, electron-provision} and never points back.
37
+ *
38
+ * @module sidecar/electron-repair-cache
39
+ */
40
+
41
+ 'use strict';
42
+
43
+ const { readArtifactBytes } = require('./electron-custody');
44
+ const { extractBytesToDist } = require('./electron-layout');
45
+ const { withNativeRescue } = require('./electron-native-rescue');
46
+ const { mayDeleteRejectedZip } = require('./electron-provision');
47
+ const { avHint } = require('./electron-quarantine');
48
+ const {
49
+ isUnsafeArchive, refuseUnsafeArchive, refuseUnreadableArtifact, rejectCachedZip,
50
+ } = require('./electron-refuse');
51
+ const { verifyArtifactBytes } = require('./electron-trust');
52
+ const { collapseExcerpt } = require('../utils/text-sanitize');
53
+
54
+ /**
55
+ * The ONE extract failure that is evidence the CACHED ARTIFACT is worthless,
56
+ * and therefore the only one that may delete it.
57
+ *
58
+ * IT IS A ONE-ENTRY ALLOW-LIST BECAUSE THE RULE HAS TO FAIL CLOSED. The first
59
+ * answer to D2 was a two-entry list of codes that KEEP the artifact, with
60
+ * everything else evicting — which failed OPEN toward deletion on every failure
61
+ * shape nobody had enumerated. MEASURED: an extractor throwing a plain `Error`
62
+ * with no `code` (an internal bug) and one throwing a raw Node `ENOSPC` BOTH
63
+ * deleted the artifact and told the user it "was corrupt and removed", which is
64
+ * D2's own shape one classification gap to the left — and D2's stated worst case
65
+ * is an air-gapped run with no network to re-fetch from.
66
+ *
67
+ * So the test is inverted: an extract failure removes the artifact only when the
68
+ * extractor positively identified the ARCHIVE as bad. Everything else — a full
69
+ * disk, an unwritable dist/, a promote that could not rename, an extractor that
70
+ * would not load, a stall, and any error a future extractor forgets to classify
71
+ * — keeps it. `zip-entry-write.js` is the other half of the contract: every
72
+ * throw there goes through one of its three constructors.
73
+ */
74
+ const EVICTS_THE_ARTIFACT = 'UNZIP_BUFFER_FAILED';
75
+
76
+ /**
77
+ * Try to provision from `zip`.
78
+ *
79
+ * @param {object} o
80
+ * @param {string} o.zip the cached artifact's path (attacker-influenced)
81
+ * @param {string} o.fileName the artifact name AMICUS resolved and validated
82
+ * @param {object} o.anchor resolveAnchor's result
83
+ * @param {object} o.policy electronTrustPolicy's result
84
+ * @param {boolean} o.cacheOnly true = never fall through to a download
85
+ * @param {function} o.extract the buffer extractor
86
+ * @param {function} o.verifyOutcome the bound AV-quarantine check
87
+ * @returns {Promise<{done:true, result:object} | {done:false, refusal:object|null}>}
88
+ * `done:false` means "fall through to the controlled download", carrying a
89
+ * refusal the caller must still surface if the download also fails.
90
+ */
91
+ async function repairFromCache({
92
+ zip, fileName, anchor, policy, electronDir, platform, arch, version,
93
+ cacheOnly, extract, verifyOutcome, fs, spawn, env = process.env, log = () => {},
94
+ }) {
95
+ const held = readArtifactBytes({ zip, fs });
96
+ if (!held.bytes) {
97
+ const refusal = refuseUnreadableArtifact({ fileName, zip, why: held.why, detail: held.detail, log });
98
+ return cacheOnly ? { done: true, result: refusal } : { done: false, refusal };
99
+ }
100
+
101
+ // C2: extraction must be unreachable for an artifact the anchor contradicts.
102
+ // A missing anchor is NOT a refusal (see verifyArtifactBytes).
103
+ const gate = verifyArtifactBytes({ bytes: held.bytes, anchor, fileName, policy, log });
104
+ if (!gate.allowed) {
105
+ const mayDelete = mayDeleteRejectedZip({ zip, fileName, env });
106
+ const refusal = rejectCachedZip({ gate, zip, mayDelete, fileName, fs, log });
107
+ return cacheOnly ? { done: true, result: refusal } : { done: false, refusal };
108
+ }
109
+
110
+ // C2: a PARSE FAILURE — and nothing else — may be answered by the native
111
+ // extractor, and only when the hatch this route's own gate already honours is
112
+ // set. `./electron-native-rescue` owns that boundary and the custody it spends;
113
+ // `rescue.used` is what stops a rescued install ever reporting a clean repair,
114
+ // because the bytes in dist/ were then placed by a child process from a path.
115
+ const rescue = {};
116
+ const extractOrRescue = withNativeRescue({ extract, gate, policy, rescue, platform, fs, spawn, log });
117
+ try {
118
+ await extractBytesToDist({ bytes: held.bytes, electronDir, platform, extract: extractOrRescue, fs });
119
+ // A non-throwing extract with no exe is the AV-quarantine signature.
120
+ const outcome = verifyOutcome();
121
+ // A2/B3, and the gap the third round found in the first answer to them: the
122
+ // mark is `verdict !== 'verified'`, NOT `verdict === 'no-digest'`. The
123
+ // strictly more alarming case is the hatch-accepted MISMATCH — amicus
124
+ // hashed the bytes and they CONTRADICT the published sha256 — and under the
125
+ // narrow test that was the one case that reported a clean repair, with
126
+ // postinstall's warning, ensureElectron's launch NOTE and `doctor --fix`'s
127
+ // UNVERIFIED count all silent. MEASURED: same bytes, same contradicting
128
+ // anchor, AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — the DOWNLOAD route marked it
129
+ // (it drops the pin and requires `verified`) and this one did not. The two
130
+ // routes now agree: `verified` is the only verdict that reports clean.
131
+ //
132
+ // C2 ADDS A SECOND WAY TO LOSE `verified`, for the same reason the first
133
+ // exists: a NATIVE RESCUE extracted a path through a child process, so
134
+ // whatever landed in dist/ is not what amicus hashed, whatever the artifact's
135
+ // own digest said.
136
+ return { done: true, result: gate.verdict === 'verified' && !rescue.used ? outcome : { ...outcome, unverified: true } };
137
+ } catch (err) {
138
+ // C4 IS A CALL-SITE INVARIANT. A path-traversal refusal must not be
139
+ // deleted-and-retried, nor reported as "corrupt" — it stops here, and the
140
+ // archive is LEFT IN PLACE, because a refused archive is evidence.
141
+ if (isUnsafeArchive(err)) { return { done: true, result: refuseUnsafeArchive({ err, fileName, log }) }; }
142
+ // D2: only a bad ARCHIVE is evidence that the cached artifact is worthless.
143
+ // A full disk, an unwritable dist/, a promote that could not rename, an
144
+ // extractor that would not load and an error nobody classified all say
145
+ // nothing about the artifact, so it keeps its bytes. (The v4.5.2 outage is
146
+ // why an unloadable extractor is in here: an undeclared zip library must not
147
+ // be able to delete a user's only artifact on its way out.)
148
+ if (!err || err.code !== EVICTS_THE_ARTIFACT) {
149
+ const refusal = {
150
+ repaired: false,
151
+ integrity: 'extract-failed',
152
+ // F5: the extractor's message can quote the ARCHIVE'S OWN entry name and
153
+ // an fs error string built from an attacker-influenced path.
154
+ reason: `Cached electron artifact ${fileName} was NOT extracted (${collapseExcerpt((err && err.message) || '')});`
155
+ + ' it was LEFT IN PLACE: only an archive amicus positively identified as bad is removed.',
156
+ };
157
+ log(`[amicus] ${refusal.reason}`);
158
+ return cacheOnly ? { done: true, result: refusal } : { done: false, refusal };
159
+ }
160
+ // C2 (round 4): AN OFFER IS A PROMISE, AND THIS IS WHERE IT IS KEPT. With the
161
+ // hatch off, the wrapper above has just printed a ten-line offer telling an
162
+ // air-gapped user to set AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 and provision
163
+ // again — and the eviction below hangs on the IDENTICAL code, so the first
164
+ // cut deleted the artifact on the way out and the promised re-run found
165
+ // nothing to rescue (MEASURED: the full offer on stderr, `was corrupt and
166
+ // removed`, `existsSync(zip) === false`, then `No cached electron zip found`).
167
+ // The cost of keeping it is the availability cost the fence below already
168
+ // accepts on a wrong `false`: it survives and is re-downloaded once per
169
+ // provision. A rescue that was named must still exist to be taken.
170
+ if (rescue.offered) {
171
+ const kept = `Cached electron zip for v${version} (${platform}-${arch}) could not be read and was `
172
+ + 'LEFT IN PLACE: the native-extractor rescue was offered on this run and needs this copy; '
173
+ + `${cacheOnly ? 'deferring re-download' : 'trying a fresh download'}.` + avHint(platform);
174
+ log(`[amicus] ${kept}`);
175
+ return cacheOnly
176
+ ? { done: true, result: { repaired: false, reason: kept } }
177
+ : { done: false, refusal: { repaired: false, integrity: 'corrupt-artifact', reason: kept } };
178
+ }
179
+ // The archive really is bad: evict it — THROUGH THE SAME FENCE the mismatch
180
+ // eviction uses — and claim only what happened.
181
+ //
182
+ // C2 (round 3): this was a bare `fs.rmSync(zip, { force: true })`. Two
183
+ // deletes of the same attacker-influenced path lived in this one module, one
184
+ // fenced and one not, and `mayDeleteRejectedZip`'s own docblock had already
185
+ // written the reason down — it called itself "STRICTLY NARROWER than the
186
+ // unconditional `fs.rmSync` on the corrupt-extract path" and left that path
187
+ // unconditional. `zip` comes out of a `readdirSync` of a directory anyone can
188
+ // write, so the fence realpaths it, requires the basename to be exactly the
189
+ // artifact amicus asked for, and requires it to resolve inside a resolved
190
+ // cache root; `containsOnDisk` returns false on any error, so an
191
+ // unresolvable path is refused rather than trusted.
192
+ //
193
+ // COST OF A WRONG `false`: a corrupt zip survives and is re-downloaded once
194
+ // per provision, and the reason below says "left in place" — the same
195
+ // availability cost the mismatch fence already accepts, never a safety one.
196
+ let removed = false;
197
+ if (mayDeleteRejectedZip({ zip, fileName, env })) {
198
+ try { fs.rmSync(zip, { force: true }); removed = true; } catch { /* an unwritable cache is not a repair failure */ }
199
+ }
200
+ const reason = `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and `
201
+ + `${removed ? 'removed' : 'left in place'}; ${cacheOnly ? 'deferring re-download' : 'trying a fresh download'}.`
202
+ + avHint(platform);
203
+ // A1: this used to leave `refusal` null on the non-cacheOnly branch, so a
204
+ // failed re-download afterwards returned a bare {repaired:false} with no
205
+ // reason at all — after deleting the user's only artifact.
206
+ return cacheOnly
207
+ ? { done: true, result: { repaired: false, reason } }
208
+ : { done: false, refusal: { repaired: false, integrity: 'corrupt-artifact', reason } };
209
+ }
210
+ }
211
+
212
+ module.exports = { repairFromCache };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * THE TWO NOTICES the native-extractor rescue speaks — the offer a parse failure
3
+ * gets when the hatch is OFF, and the window disclosure printed when it is ON.
4
+ *
5
+ * SPLIT OUT of `./electron-refuse` (v4.9.6, C2 round 4): that file sits at the
6
+ * repo's 300-line gate and these two functions pushed it two lines over. The
7
+ * seam is the one it already had — everything in both files composes a message
8
+ * a user reads and decides nothing. `./electron-native-rescue` owns which
9
+ * failures reach either of these, and it is the only caller.
10
+ *
11
+ * WHY THE WORDS ARE THIS BLUNT. The rescue writes bytes amicus hashed to a path
12
+ * and hands that path to a child process, which is the one thing the rest of the
13
+ * provisioning exists to avoid. Neither notice may describe that as safe; both
14
+ * say what the window is while it is open. F5's sanitizer runs on everything
15
+ * here that quotes an extractor message or a path.
16
+ *
17
+ * NEAR-LEAF: `./electron-refuse` (for `PATH_EXCERPT_CHARS`) and
18
+ * `../utils/text-sanitize`. Nothing requires it back.
19
+ *
20
+ * @module sidecar/electron-rescue-notice
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const { PATH_EXCERPT_CHARS } = require('./electron-refuse');
26
+ const { collapseExcerpt } = require('../utils/text-sanitize');
27
+
28
+ /**
29
+ * THE OFFER a parse failure gets when the native-extractor rescue is NOT armed.
30
+ *
31
+ * It NAMES `AMICUS_ALLOW_UNVERIFIED_ELECTRON` and says what setting it would do,
32
+ * so an air-gapped user whose archive amicus cannot read can find the escape
33
+ * hatch without reading the source — the whole point of the C2 finding. It is
34
+ * printed for a PARSE FAILURE and nothing else: a security refusal must never be
35
+ * answered with an offer to retry, which is C4 with a human in the loop.
36
+ * `electron-native-rescue.js` owns which failures reach here.
37
+ *
38
+ * AND IT PROMISES THE ARCHIVE IS STILL THERE, which is a claim its callers now
39
+ * keep: printing this offer sets `rescue.offered`, and the cache route reads that
40
+ * and does NOT evict (`electron-repair-cache.js`). The first cut said all of the
41
+ * above and then deleted the artifact on the way out, so the re-run it asks for
42
+ * had nothing to rescue — the last sentence exists to be falsifiable.
43
+ */
44
+ function offerNativeRescue({ reason, log = () => {} }) {
45
+ log(`[amicus] amicus could not read this Electron archive: ${collapseExcerpt(reason)}`);
46
+ log('[amicus] There is ONE rescue for that, and it is OFF. With');
47
+ log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 set BEFORE provisioning, amicus writes the bytes');
48
+ log('[amicus] it hashed into a private directory inside the electron package and hands that PATH');
49
+ log('[amicus] to a native extractor (tar / Expand-Archive / ditto / unzip) — the only way an');
50
+ log('[amicus] archive amicus cannot parse becomes an install on a machine with no network to');
51
+ log('[amicus] re-download from. It COSTS custody: between amicus writing that file and the child');
52
+ log('[amicus] process opening it, anything running as your user can substitute it, and what the');
53
+ log('[amicus] child extracts is promoted into dist/ without ever being hashed again. That is not');
54
+ log('[amicus] safe — it is the trade the flag buys. Prefer a different copy of the artifact.');
55
+ log('[amicus] This run has NOT discarded the archive it could not read: the copy you have is still');
56
+ log('[amicus] there, so setting the variable and running again has something to act on.');
57
+ }
58
+
59
+ /**
60
+ * THE NOTICE a user sees when the rescue actually runs. Printed BEFORE the child
61
+ * is spawned, because the window opens at the write, and it DESCRIBES that window
62
+ * instead of reassuring anyone about it — the rescue is not safe, and the words a
63
+ * user reads while it happens have to say so.
64
+ */
65
+ function announceNativeRescue({
66
+ zip, reason, namesComplete, namesChecked = 0, log = () => {},
67
+ }) {
68
+ log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — running the NATIVE-EXTRACTOR RESCUE.');
69
+ log(`[amicus] amicus could not read the archive itself: ${collapseExcerpt(reason)}`);
70
+ // THREE STATES, NOT TWO, and the middle one is the common one: a real artifact
71
+ // truncated by a few KB leaves both walks incomplete while the local walk still
72
+ // read and cleared every name it reached. Saying "nothing checked its entries"
73
+ // there would be a FALSE disclosure on the shape this rescue exists for.
74
+ // `namesComplete` is undefined-means-no on purpose (see `nativeRescue`).
75
+ if (!namesComplete && namesChecked > 0) {
76
+ log(`[amicus] IT CHECKED ${namesChecked} ENTRY NAMES AND COULD NOT CONFIRM IT SAW THEM ALL:`);
77
+ log('[amicus] the archive stopped amicus part-way through its own tables, so an entry that');
78
+ log('[amicus] writes OUTSIDE dist/ could sit past the point it reached.');
79
+ } else if (!namesComplete) {
80
+ log('[amicus] AND IT COULD NOT READ THE ENTRY NAMES EITHER: neither this archive\'s central');
81
+ log('[amicus] directory nor its local file headers could be walked, so NOTHING checked its');
82
+ log('[amicus] entries for paths that write OUTSIDE dist/. The extractor below is the only');
83
+ log('[amicus] check left.');
84
+ }
85
+ log('[amicus] THE WINDOW THIS OPENS, stated plainly. amicus has written the bytes it hashed to');
86
+ log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
87
+ log('[amicus] and is about to hand that PATH to a native extractor it does not control. Between');
88
+ log('[amicus] the write and the child opening the file, anything running as your user can');
89
+ log('[amicus] replace it, and whatever the child extracts is promoted into dist/ WITHOUT being');
90
+ log('[amicus] hashed again. So this run is not covered by the property the rest of the');
91
+ log('[amicus] provisioning holds to — that amicus only ever writes bytes it hashed. It is NOT');
92
+ log('[amicus] safe; it is what the flag buys, and the result is reported as unverified.');
93
+ }
94
+
95
+ module.exports = { offerNativeRescue, announceNativeRescue };
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
2
+ * Electron artifact TRUST core — the digest anchor and the gate. (The third
3
+ * member of the cluster, the installer-spawn env scrub, lives in
4
+ * ./electron-env-scrub and is re-exported here; see below.)
3
5
  *
4
6
  * A hostile REPOSITORY (a clone the user opens, an unpacked sample) controls the
5
7
  * `.npmrc` and `package.json` of the directory amicus's own docs tell people to
@@ -18,11 +20,21 @@
18
20
  * electron's own published sha256. Blocking the URL itself is defence in depth on
19
21
  * top of a control that already works, and is deliberately NOT built here.
20
22
  *
21
- * LEAF MODULE: `crypto` + `path` + `fs` and nothing from this repo. The arrow is
22
- * electron-install -> electron-provision -> electron-trust and must never point
23
+ * NEAR-LEAF MODULE: `crypto` + `path` + `fs`, plus `./electron-env-scrub`, which
24
+ * is itself a true leaf (no requires at all). The arrow is electron-install ->
25
+ * electron-provision -> electron-trust -> electron-env-scrub and must never point
23
26
  * back; src/utils/path-fence.js:11-17 records what a cycle does to a destructured
24
27
  * import in exactly this cluster.
25
28
  *
29
+ * THE ENV SCRUB LIVES NEXT DOOR (v4.9.6 F2). `isRepoPlantedName` and
30
+ * `REPO_ENV_PREFIXES` moved to `./electron-env-scrub` when this file hit the
31
+ * 300-line gate with the F2 repair still to land, and are RE-EXPORTED here so
32
+ * existing import paths stay valid — the same shape as engine-log-parse.js
33
+ * re-exporting utils/text-sanitize.js. There is ONE implementation; these are
34
+ * the same function objects, not a second copy. (`scrubbedChildEnv` and
35
+ * `ELECTRON_INSTALL_TARGET_ENV` were deleted with the install.js spawn they
36
+ * served; see electron-provision.js for why that spawn is gone.)
37
+ *
26
38
  * @module sidecar/electron-trust
27
39
  */
28
40
 
@@ -32,53 +44,7 @@ const crypto = require('crypto');
32
44
  const fsDefault = require('fs');
33
45
  const path = require('path');
34
46
 
35
- /**
36
- * Env-name PREFIXES an untrusted REPOSITORY can plant. MEASURED, npm 11: a repo
37
- * .npmrc key `k` reaches an `npm run` / `npm exec` child as
38
- * `npm_config_<k lowercased>`; a repo package.json "config" key `k` reaches it as
39
- * `npm_package_config_<k>` with case preserved. Nothing else.
40
- *
41
- * PREFIXES, not a hand-maintained name list. Two prefixes cover every
42
- * `@electron/get` mirror knob in each repo-reachable spelling, plus electron's
43
- * own `npm_config_electron_use_remote_checksums` (electron's install.js, lines
44
- * 47-50 — that name turns electron's bundled pin OFF), plus any knob a future
45
- * @electron/get adds in the same namespace. Contrast ENGINE_CREDENTIAL_ENV
46
- * (scripts/run-integration-keyless.js:101), whose own docblock warns that nothing
47
- * makes a name list follow an upstream bump.
48
- *
49
- * The BARE `electron_use_remote_checksums` is deliberately NOT removed: a bare
50
- * lower-case name is not repo-injectable, so it carries the machine owner's
51
- * intent, exactly like a bare `ELECTRON_MIRROR`.
52
- *
53
- * MATCHED CASE-INSENSITIVELY. This used to fold no case, on the claim that
54
- * because the Windows environment block is case-insensitive, deleting the
55
- * lower-case name also removed the `NPM_CONFIG_ELECTRON_*` view @electron/get
56
- * reads second. That is true of `process.env` and FALSE of the `{...env}` PLAIN
57
- * OBJECT this module actually deletes from — a plain object is case-sensitive on
58
- * every platform, so the upper-case key survived and was handed to the child.
59
- * RE-MEASURED (npm 11.16.0, Windows 11) — two ways a repository reaches an
60
- * upper-case slot:
61
- * 1. `.npmrc` `electron_mirror=…` while `NPM_CONFIG_ELECTRON_MIRROR` already
62
- * exists in the environment: npm overwrites that slot's VALUE and never
63
- * renames it, so the child sees the ATTACKER's URL under the upper-case name.
64
- * 2. `package.json` `"config": {"ELECTRON_MIRROR": …}`: npm PRESERVES the key's
65
- * case, planting `npm_package_config_ELECTRON_MIRROR` with nothing
66
- * pre-existing at all — and @electron/get's own lookup for
67
- * `npm_package_config_electron_mirror` (dist/artifact-utils.js, line 28) finds it,
68
- * because the Windows lookup is case-insensitive too.
69
- * The old docblock's POSIX half (`NPM_CONFIG_ELECTRON_*` is a distinct variable
70
- * npm never writes there, so it is the machine owner's) is NOT measurable from
71
- * this machine, and it is load-bearing in the fail-OPEN direction: wrong, it
72
- * hands the child an attacker's mirror. Wrong the other way it costs one
73
- * alternate spelling inside a last-resort spawn, while bare `ELECTRON_MIRROR`
74
- * — which @electron/get ranks FIRST — still carries owner intent. So the fold is
75
- * unconditional rather than resting on an unverified platform claim.
76
- */
77
- const REPO_ENV_PREFIXES = ['npm_config_electron_', 'npm_package_config_electron_'];
78
-
79
- /** electron's install.js, lines 20-21 and 99 — these choose WHICH artifact it
80
- * fetches, and `.npmrc` `platform=`/`arch=` plants both. Same case fold. */
81
- const ELECTRON_INSTALL_TARGET_ENV = ['npm_config_platform', 'npm_config_arch'];
47
+ const { isRepoPlantedName, REPO_ENV_PREFIXES } = require('./electron-env-scrub');
82
48
 
83
49
  /** A published sha256 is 64 LOWER-case hex characters. Anything else is not an anchor. */
84
50
  const HEX64 = /^[0-9a-f]{64}$/;
@@ -89,13 +55,6 @@ function normalizeV(version) {
89
55
  return v.startsWith('v') ? v : `v${v}`;
90
56
  }
91
57
 
92
- /** True for a name a hostile repository could have planted, in ANY case (see above). */
93
- function isRepoPlantedName(name) {
94
- const lower = String(name).toLowerCase();
95
- return REPO_ENV_PREFIXES.some((prefix) => lower.startsWith(prefix))
96
- || ELECTRON_INSTALL_TARGET_ENV.includes(lower);
97
- }
98
-
99
58
  /**
100
59
  * The amicus-namespaced trust policy. THE ONLY place the escape hatch is read.
101
60
  * Never reads npm_config_* / npm_package_config_* / NPM_CONFIG_* — those are the
@@ -201,53 +160,48 @@ function expectedDigest(anchor, fileName) {
201
160
  return typeof digest === 'string' && HEX64.test(digest) ? digest : null;
202
161
  }
203
162
 
204
- /** Chunked sha256 (1 MiB buffer, openSync/readSync). SYNC so it composes with
205
- * repairElectron's injected `fs`; chunked so a ~170 MB zip is never buffered whole. */
206
- function sha256File(file, fs = fsDefault) {
207
- const hash = crypto.createHash('sha256');
208
- const buffer = Buffer.alloc(1024 * 1024);
209
- const fd = fs.openSync(file, 'r');
210
- try {
211
- let read = fs.readSync(fd, buffer, 0, buffer.length, null);
212
- while (read > 0) {
213
- hash.update(buffer.subarray(0, read));
214
- read = fs.readSync(fd, buffer, 0, buffer.length, null);
215
- }
216
- } finally {
217
- try { fs.closeSync(fd); } catch { /* already closed */ }
218
- }
219
- return hash.digest('hex');
163
+ /** sha256 of an artifact amicus already holds in its own heap. */
164
+ function sha256Bytes(bytes) {
165
+ return crypto.createHash('sha256').update(bytes).digest('hex');
220
166
  }
221
167
 
222
168
  /**
223
- * THE GATE. NEVER THROWS. `allowed` is the single decision bit callers act on.
169
+ * THE GATE, OVER BYTES. NEVER THROWS. `allowed` is the single decision bit.
224
170
  *
225
171
  * @returns {{verdict:'verified', allowed:true, actual:string}
226
172
  * | {verdict:'mismatch', allowed:boolean, expected:string, actual:string}
227
- * | {verdict:'no-digest', allowed:true}
228
- * | {verdict:'unreadable', allowed:false, reason:string}}
173
+ * | {verdict:'no-digest', allowed:true}}
174
+ *
175
+ * IT TAKES A BUFFER, AND THE PATH FORM IS GONE. `verifyArtifact({zip, ...})` and
176
+ * `sha256File` were DELETED in the v4.9.6 second council round, not deprecated.
177
+ * Hashing a path and then handing that path to an extractor is the race three
178
+ * seats filed against v4.9.5, and hashing a private COPY of it is the race a
179
+ * fourth seat filed against the remedy — a same-uid attacker opens the copy too
180
+ * (MEASURED). Leaving a path-hashing gate exported and callable is an invitation
181
+ * to reintroduce it, and no caller is left that could legitimately want one. The
182
+ * bytes now arrive from `electron-custody.readArtifactBytes`, which reads them
183
+ * once through one descriptor, and the SAME Buffer is what `zip-from-buffer`
184
+ * extracts.
185
+ *
186
+ * `unreadable` disappeared with the path form. Unreadability is decided BEFORE
187
+ * any hashing now, by `readArtifactBytes`, and the caller refuses there — bytes
188
+ * that could not be read never reach this function, so there is no verdict for
189
+ * them to carry.
229
190
  *
230
191
  * `no-digest` is ALLOWED and merely marked. An electron package that predates
231
192
  * `checksums.json` has no anchor through no fault of its own, and refusing it
232
193
  * would push that machine into a permanent re-download loop for a file no
233
- * download can improve.
234
- *
235
- * `unreadable` is refused whatever the policy says: bytes that cannot be hashed
236
- * cannot be extracted either, so there is nothing to fail open to.
194
+ * download can improve. THE NOTE BELOW IS THE CACHE ROUTE'S stderr line, the one
195
+ * `docs/troubleshooting.md` promises; the download route prints its own.
237
196
  */
238
- function verifyArtifact({ zip, anchor, fileName, policy = {}, fs = fsDefault, log = () => {} }) {
197
+ function verifyArtifactBytes({ bytes, anchor, fileName, policy = {}, log = () => {} }) {
239
198
  const expected = expectedDigest(anchor, fileName);
240
199
  if (!expected) {
241
200
  log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
242
201
  log('[amicus] checksums.json entry for it), so its bytes could not be verified.');
243
202
  return { verdict: 'no-digest', allowed: true };
244
203
  }
245
- let actual;
246
- try {
247
- actual = sha256File(zip, fs);
248
- } catch (e) {
249
- return { verdict: 'unreadable', allowed: false, reason: `could not hash ${fileName}: ${(e && e.message) || e}` };
250
- }
204
+ const actual = sha256Bytes(bytes);
251
205
  if (actual === expected) { return { verdict: 'verified', allowed: true, actual }; }
252
206
  if (policy.allowUnverified) {
253
207
  log(`[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — accepting ${fileName} even though`);
@@ -258,42 +212,15 @@ function verifyArtifact({ zip, anchor, fileName, policy = {}, fs = fsDefault, lo
258
212
  return { verdict: 'mismatch', allowed: false, expected, actual };
259
213
  }
260
214
 
261
- /**
262
- * A COPY of env for the runInstaller SPAWN. Never mutates the argument.
263
- *
264
- * electron's own install.js honours `npm_config_electron_mirror` (through
265
- * @electron/get) AND `npm_config_electron_use_remote_checksums` (its lines 47-50,
266
- * which turns its bundled pin off), so spawning it with an unfiltered
267
- * `{...process.env}` would funnel a blocked attacker straight into an unpinned
268
- * downloader. `npm_config_platform` / `npm_config_arch` (its lines 20-21 and 99)
269
- * choose WHICH artifact it fetches, so they are removed too and amicus's own
270
- * resolution is pinned through `ELECTRON_INSTALL_PLATFORM`/`_ARCH`, which
271
- * install.js ranks above them.
272
- *
273
- * LEAVES ALONE, deliberately — every one of these is a BARE name a repository
274
- * cannot plant, so it is the machine owner's: `ELECTRON_MIRROR`,
275
- * `ELECTRON_CUSTOM_*`, `electron_config_cache`, `ELECTRON_CACHE`,
276
- * `electron_use_remote_checksums`, `HTTP_PROXY`/`HTTPS_PROXY`/`ELECTRON_GET_USE_PROXY`.
277
- */
278
- function scrubbedChildEnv({ env = process.env, platform, arch } = {}) {
279
- const out = { ...env };
280
- for (const name of Object.keys(out)) {
281
- if (isRepoPlantedName(name)) { delete out[name]; }
282
- }
283
- if (platform) { out.ELECTRON_INSTALL_PLATFORM = platform; }
284
- if (arch) { out.ELECTRON_INSTALL_ARCH = arch; }
285
- return out;
286
- }
287
-
288
215
  module.exports = {
289
216
  electronTrustPolicy,
290
217
  resolveAnchor,
291
218
  expectedDigest,
292
- verifyArtifact,
293
- sha256File,
219
+ verifyArtifactBytes,
220
+ sha256Bytes,
294
221
  artifactFileName,
295
- scrubbedChildEnv,
296
222
  normalizeV,
223
+ // RE-EXPORTED from ./electron-env-scrub — the same function objects, not copies.
224
+ isRepoPlantedName,
297
225
  REPO_ENV_PREFIXES,
298
- ELECTRON_INSTALL_TARGET_ENV,
299
226
  };
@@ -30,6 +30,10 @@ const path = require('path');
30
30
  const fsDefault = require('fs');
31
31
  const { spawnSync } = require('child_process');
32
32
 
33
+ // F5: refusals quote the ARCHIVE'S OWN entry name, so every message built from
34
+ // one is sanitized before it reaches stderr or an Error a caller prints.
35
+ const { collapseExcerpt } = require('../utils/text-sanitize');
36
+
33
37
  /**
34
38
  * A SECURITY REFUSAL IS A REFUSAL, NOT A RETRY (M9).
35
39
  *
@@ -48,6 +52,11 @@ const { spawnSync } = require('child_process');
48
52
  *
49
53
  * DELIBERATELY NARROW. A stall must still fall back, or the Node-24 workaround
50
54
  * this whole module exists for is destroyed.
55
+ *
56
+ * AND THE VERIFICATION NO LONGER DECAYS (v4.9.6 F4). "Verified against the
57
+ * installed versions" had nothing keeping it true; tests/sidecar/unzip-refusal-strings.js
58
+ * now drives the INSTALLED libraries into producing all four for real, and fails
59
+ * both on a reworded refusal and on a pattern no real message produces.
51
60
  */
52
61
  const UNSAFE_PATTERNS = [
53
62
  /^Out of bound path /,
@@ -237,13 +246,13 @@ async function robustExtract(zip, opts = {}) {
237
246
  // archive to tar/Expand-Archive would ask a tool with no such check to do what
238
247
  // extract-zip just declined to.
239
248
  if (!z.ok && UNSAFE_PATTERNS.some((p) => p.test(z.reason || ''))) {
240
- const err = new Error(`refusing to extract ${zip}: ${z.reason}`);
249
+ const err = new Error(`refusing to extract ${zip}: ${collapseExcerpt(z.reason)}`);
241
250
  err.code = 'UNZIP_UNSAFE_ARCHIVE';
242
251
  throw err;
243
252
  }
244
253
 
245
254
  // extract-zip stalled / threw / produced nothing → clean partial output, go native.
246
- const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
255
+ const zipReason = z.ok ? 'extract-zip produced no files' : collapseExcerpt(z.reason);
247
256
  cleanDir(fs, dir);
248
257
  log(`[amicus] extract-zip did not complete (${zipReason}); falling back to native unzip.`);
249
258
 
@@ -283,4 +292,5 @@ async function robustExtract(zip, opts = {}) {
283
292
  throw err;
284
293
  }
285
294
 
286
- module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS };
295
+ // UNSAFE_PATTERNS is exported for the F4 upstream-drift probe (see its docblock).
296
+ module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS, UNSAFE_PATTERNS };