amicus 4.9.5 → 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 +86 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +31 -7
- package/docs/configuration.md +91 -11
- package/docs/troubleshooting.md +96 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-install.js +115 -125
- 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 +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +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,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 };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Electron artifact TRUST core — the digest anchor
|
|
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
|
|
22
|
-
*
|
|
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
|
-
/**
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
};
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|