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,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 };
|