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.
@@ -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
+ };
@@ -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,78 @@
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({ zip, reason, log = () => {} }) {
66
+ log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — running the NATIVE-EXTRACTOR RESCUE.');
67
+ log(`[amicus] amicus could not read the archive itself: ${collapseExcerpt(reason)}`);
68
+ log('[amicus] THE WINDOW THIS OPENS, stated plainly. amicus has written the bytes it hashed to');
69
+ log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
70
+ log('[amicus] and is about to hand that PATH to a native extractor it does not control. Between');
71
+ log('[amicus] the write and the child opening the file, anything running as your user can');
72
+ log('[amicus] replace it, and whatever the child extracts is promoted into dist/ WITHOUT being');
73
+ log('[amicus] hashed again. So this run is not covered by the property the rest of the');
74
+ log('[amicus] provisioning holds to — that amicus only ever writes bytes it hashed. It is NOT');
75
+ log('[amicus] safe; it is what the flag buys, and the result is reported as unverified.');
76
+ }
77
+
78
+ module.exports = { offerNativeRescue, announceNativeRescue };