From 8b109f177e7eac70bf1203a05e39433d1c83ebb0 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Sun, 1 Nov 2026 23:46:12 +0600 Subject: [PATCH] fix(platform): stop macOS Gatekeeper from blocking the nodeup binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS marks downloaded files with the `com.apple.quarantine` extended attribute, and refuses to execute an unsigned binary that still carries it. The kernel kills the process before `main()` ever runs: ASP: Security policy would not allow process: 82275, /opt/homebrew/Cellar/nodeup/1.2.1/bin/nodeup `nodeup` ships as an unsigned Go binary (no Apple Developer ID certificate), so this was fatal for it — the command produced no output and no error, it just died. Homebrew quarantines every artifact it downloads and propagates the attribute to the files it extracts, so `brew install dipto0321/tap/nodeup` produced a `nodeup` that could not be executed. The repair has to happen out of band, at install time: a quarantined executable is killed before its first instruction, so no amount of self-repair code inside the binary can strip the attribute. Both download channels now strip it as part of the install: - The Homebrew cask carries a `postflight_steps` hook that runs `xattr -dr com.apple.quarantine` on the staged path, so the attribute is gone before the binary is linked into the prefix. This is the remedy Homebrew's own cask guidance recommends. - The npm wrapper's `install.js` strips the attribute from the extracted binary before it is moved into place. Both paths are best-effort — an install never fails because the attribute could not be removed — and both are no-ops on platforms without the attribute. Verified end-to-end against the exact cask GoReleaser renders, installed with a real `brew install --cask`: the binary comes out with no quarantine attribute and runs 3/3 times. The control (a formula install, which is what the tap shipped before) reproduces the original symptom exactly: quarantine present, 0/3 runs. Closes #132. --- .goreleaser.yaml | 24 +++++++ CHANGELOG.md | 41 ++++++++++- docs/installation.md | 57 +++++++++++++++ nodeup-npm/scripts/install.js | 62 ++++++++++++++++ nodeup-npm/scripts/install_test.js | 112 ++++++++++++++++++++++++++++- 5 files changed, 293 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 89f5713..9e24069 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -140,6 +140,30 @@ homebrew_casks: license: 'MIT' binaries: - nodeup + # Strip the macOS Gatekeeper quarantine attribute after install. + # + # Homebrew quarantines every artifact it downloads (Cask::Quarantine + # .cask! in Homebrew's source) and propagates the attribute to the + # files it extracts. nodeup ships as an unsigned Go binary, so a + # quarantined copy cannot be executed at all: macOS's kernel kills + # the process with + # + # ASP: Security policy would not allow process: , + # + # before main() ever runs — `nodeup version` just dies. Removing + # `com.apple.quarantine` is the remedy Homebrew's own cask guidance + # recommends (and what GoReleaser's cask docs point at). + # + # The hook runs `xattr -dr` on the staged path so it covers the + # extracted binary. `on_macos` keeps it a no-op on Linux. The `run` + # step is not `must_succeed`-critical: if xattr is unavailable the + # install should still complete rather than abort. + hooks: + post: + install_steps: | + on_macos do + run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{ .StagedPath }}"], must_succeed: false + end # Scoop bucket — auto-pushed on every release. Requires SCOOP_BUCKET_TOKEN. scoops: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa0b2c..41871bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.2] - 2026-11-01 -A patch release that **stops the snapshot leak** and gives the user -explicit control over whatever is left on disk. +A patch release that **stops the snapshot leak**, gives the user +explicit control over whatever is left on disk, and **unblocks macOS +Gatekeeper** so the CLI actually runs after installation. Nothing in nodeup ever deleted a snapshot file. `nodeup upgrade` writes one snapshot of the global npm packages per installed Node @@ -47,6 +48,42 @@ random-suffixed filenames. file that the snapshot lister silently skipped — an invisible orphan that could never be cleaned up. Writes go through a `.tmp` file plus a rename. +- **macOS Gatekeeper could block `nodeup` from running at all.** + macOS stamps files downloaded from the internet with the + `com.apple.quarantine` extended attribute, and Gatekeeper refuses + to execute an unsigned binary that still carries it — the kernel + kills the process *before* `main()` ever runs: + + ``` + ASP: Security policy would not allow process: 82275, /opt/homebrew/Cellar/nodeup/1.2.1/bin/nodeup + ``` + + `nodeup` ships as an unsigned Go binary (no Apple Developer ID + certificate), so this was fatal for it: the command produced no + output and no error, it just died. Homebrew quarantines every + artifact it downloads and propagates the attribute to the files it + extracts, so `brew install dipto0321/tap/nodeup` produced a + `nodeup` that could not be executed. + + The fix is at install time, in both download channels: + + - The Homebrew cask now carries a `postflight_steps` hook that runs + `xattr -dr com.apple.quarantine` on the staged path, so the + attribute is gone before the binary is linked into the prefix. + - The npm wrapper's `install.js` strips the attribute from the + extracted binary before it is moved into place. + + Both paths are best-effort — an install never fails because the + attribute could not be removed — and both are no-ops on platforms + without the attribute. The recovery command for an already-broken + install is `xattr -dr com.apple.quarantine "$(command -v nodeup)"`; + see `docs/installation.md` for the details, including the + `chmod u+w` variant needed when the binary is not writable. + + Note that the fix **cannot** live inside the Go binary: a + quarantined executable is killed before its first instruction, so + `main()` never runs and no amount of self-repair code can strip the + attribute. Closes #132. ### Added - `nodeup packages cleanup` — delete the snapshot and migration-report diff --git a/docs/installation.md b/docs/installation.md index faf2d15..fd58c8b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -140,6 +140,13 @@ reproducible across CI runs. script it) when you want a new version. The binary is fully self-contained — no runtime, no Node, no manager. +**On macOS:** if you downloaded the archive through a browser, the +extracted binary can carry the Gatekeeper quarantine attribute and +macOS will refuse to run it. Strip it with +`xattr -dr com.apple.quarantine nodeup` after extracting. See +[macOS Gatekeeper](#macos-gatekeeper-security-policy-would-not-allow-process) +for the full story. + ### From source ```bash @@ -165,6 +172,56 @@ nodeup version Should print a version, git commit, build date, and Go runtime info. +## macOS Gatekeeper ("Security policy would not allow process") + +macOS marks files that came from the internet with an extended +attribute called `com.apple.quarantine`. Gatekeeper then refuses to +execute an **unsigned** binary that still carries that attribute — +the kernel kills the process *before* `main()` ever runs, so the +command dies with no output at all: + +``` +ASP: Security policy would not allow process: 82275, /opt/homebrew/Cellar/nodeup/1.2.1/bin/nodeup +``` + +`nodeup` ships as an unsigned Go binary (no Apple Developer ID +certificate), so this used to hit anyone who installed it through a +channel that let the attribute survive. As of **v1.2.2** every +channel strips the attribute as part of the install: + +| Channel | How the attribute is removed | +|---|---| +| Homebrew | The cask's `postflight_steps` hook runs `xattr -dr com.apple.quarantine` on the staged path after install. | +| npm wrapper | `nodeup-npm/scripts/install.js` strips the attribute from the extracted binary before it is moved into place. | +| Scoop | Windows only — the attribute does not exist. | +| From source | `go install` compiles locally — nothing is downloaded, so nothing is quarantined. | + +### If you are already stuck + +If `nodeup` dies silently with no output, macOS is blocking it. Strip +the attribute by hand: + +```bash +xattr -dr com.apple.quarantine "$(command -v nodeup)" +``` + +If that reports `Permission denied`, the file is not writable — make +it writable first, then restore the mode: + +```bash +BIN="$(command -v nodeup)" +chmod u+w "$BIN" && xattr -d com.apple.quarantine "$BIN" && chmod 555 "$BIN" +``` + +Reinstalling through Homebrew or npm also fixes it, because both +channels strip the attribute as part of the install. + +> **Note:** `tar` and `zip` extraction both *propagate* the quarantine +> attribute to the files they unpack, so a browser-downloaded archive +> can leave the extracted binary quarantined too. That is why the +> direct-binary section above is followed by the `xattr` one-liner +> whenever the download came through a browser. + ## Picking once and sticking You can install via multiple channels at once if you want — they'll diff --git a/nodeup-npm/scripts/install.js b/nodeup-npm/scripts/install.js index 565f75b..c4618d5 100644 --- a/nodeup-npm/scripts/install.js +++ b/nodeup-npm/scripts/install.js @@ -427,6 +427,64 @@ function chmodExec(filePath) { fs.chmodSync(filePath, 0o755); } +// stripQuarantine removes the macOS Gatekeeper quarantine attribute +// (`com.apple.quarantine`) from a path, recursively. +// +// Why this matters: macOS marks files downloaded from the internet with +// that attribute, and refuses to execute an unsigned binary that still +// carries it — the kernel kills the process with +// +// ASP: Security policy would not allow process: , +// +// before `main()` ever runs, so the CLI just dies silently. nodeup +// ships as an unsigned Go binary, so this is fatal for it. +// +// The npm path is normally safe (Node's `https` download does not set +// the attribute), but it is not guaranteed: a corporate proxy, a +// mirror, or a future change to how the archive is produced can leave +// the attribute set. Stripping it here makes the install robust +// regardless of how the archive arrived. +// +// Two macOS details: +// +// - `xattr -d` refuses to modify a file that is not writable by the +// caller. `chmodExec()` runs first and leaves the binary at 0755 +// (owner-writable), so the removal succeeds. +// - Removing the attribute from a directory does not remove it from +// the files inside, hence the recursive `-r`. +// +// Best-effort by design: any failure is reported but never aborts the +// install. On a machine where the attribute was never set there is +// nothing to do, and the extra work is a no-op. +function stripQuarantine(targetPath) { + if (process.platform !== 'darwin') return false; + try { + // `-p` prints the value; a non-zero exit means "not present". + const probe = execFileSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', targetPath], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (!probe || probe.length === 0) return false; + } catch (_) { + // Attribute absent (or xattr unavailable) — nothing to strip. + return false; + } + + try { + execFileSync('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', targetPath], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + info(`stripped the macOS quarantine attribute from ${path.basename(targetPath)}`); + return true; + } catch (err) { + info( + `could not strip the macOS quarantine attribute from ${path.basename(targetPath)} ` + + `(${err.message}). If macOS blocks nodeup with "Security policy would not allow ` + + `process", run: xattr -dr com.apple.quarantine ${targetPath}` + ); + return false; + } +} + // ---- main ----------------------------------------------------------------- (async function main() { @@ -501,6 +559,10 @@ function chmodExec(filePath) { } fs.renameSync(extractedPath, binaryDest); chmodExec(binaryDest); + // macOS Gatekeeper: a quarantined binary cannot be executed at all + // (the kernel kills it before main() runs). Remove the attribute so + // `nodeup` is always runnable after install. + stripQuarantine(binaryDest); info(`installed nodeup v${version} (${os}/${goarch}) -> ${binaryDest}`); } finally { diff --git a/nodeup-npm/scripts/install_test.js b/nodeup-npm/scripts/install_test.js index 3b65ee6..924467d 100644 --- a/nodeup-npm/scripts/install_test.js +++ b/nodeup-npm/scripts/install_test.js @@ -451,5 +451,115 @@ test('extractTarGz_filterRejectsTraversalEntry', async function () { fs.rmSync(tmp, { recursive: true, force: true }); }); +// --- mirrored from install.js: stripQuarantine ---------------------------- +// +// macOS Gatekeeper refuses to execute an unsigned binary that still +// carries the `com.apple.quarantine` attribute — the kernel kills the +// process before `main()` ever runs, so `nodeup` dies with no output. +// install.js strips the attribute after extraction. See #18. +// +// The real stripQuarantine() shells out to /usr/bin/xattr. We test the +// decision logic it implements (platform gate, probe-first, best-effort) +// with a fake exec so the test is hermetic and runs on every platform. + +const QUARANTINE_ATTR = 'com.apple.quarantine'; + +function stripQuarantineTest({ platform, exec, targetPath }) { + // Not macOS: nothing to do, and we must not shell out at all. + if (platform !== 'darwin') return false; + + // Probe first. A non-zero exit means "attribute not present", which + // is the common case and must be a silent no-op. + let probe; + try { + probe = exec(['-p', QUARANTINE_ATTR, targetPath]); + } catch (_) { + return false; + } + if (!probe || probe.length === 0) return false; + + // Attribute present: strip it. A failure here must NOT throw — the + // install has to complete either way. + try { + exec(['-dr', QUARANTINE_ATTR, targetPath]); + } catch (_) { + return false; + } + return true; +} + +// stripQuarantine: on a non-darwin platform the function is a no-op and +// never touches the filesystem. +test('stripQuarantine_nonDarwinIsANoOp', () => { + let called = false; + const exec = () => { + called = true; + return Buffer.from('0081;abc'); + }; + const result = stripQuarantineTest({ + platform: 'linux', + exec, + targetPath: '/tmp/nodeup', + }); + assert.strictEqual(result, false); + assert.strictEqual(called, false, 'must not shell out on non-darwin'); +}); + +// stripQuarantine: the attribute is absent (xattr exits non-zero) — +// nothing happens and the function reports "did not strip". +test('stripQuarantine_absentAttributeIsANoOp', () => { + const calls = []; + const exec = (args) => { + calls.push(args); + throw new Error('xattr: No such xattr: com.apple.quarantine'); + }; + const result = stripQuarantineTest({ + platform: 'darwin', + exec, + targetPath: '/tmp/nodeup', + }); + assert.strictEqual(result, false); + assert.strictEqual(calls.length, 1, 'only the probe should run'); + assert.deepStrictEqual(calls[0], ['-p', QUARANTINE_ATTR, '/tmp/nodeup']); +}); + +// stripQuarantine: the attribute is present — it is removed with the +// recursive form so nested files are covered too. +test('stripQuarantine_presentAttributeIsStrippedRecursively', () => { + const calls = []; + const exec = (args) => { + calls.push(args); + if (args[0] === '-p') return Buffer.from('0081;5f4a;0;abc'); + return Buffer.from(''); + }; + const result = stripQuarantineTest({ + platform: 'darwin', + exec, + targetPath: '/tmp/nodeup', + }); + assert.strictEqual(result, true); + assert.strictEqual(calls.length, 2, 'probe then strip'); + assert.deepStrictEqual(calls[0], ['-p', QUARANTINE_ATTR, '/tmp/nodeup']); + assert.deepStrictEqual(calls[1], ['-dr', QUARANTINE_ATTR, '/tmp/nodeup']); +}); + +// stripQuarantine: if the removal fails, the install must still +// complete — the function returns false instead of throwing. +test('stripQuarantine_removalFailureDoesNotAbortTheInstall', () => { + const exec = (args) => { + if (args[0] === '-p') return Buffer.from('0081;abc'); + throw new Error('xattr: Permission denied'); + }; + let threw = false; + let result = null; + try { + result = stripQuarantineTest({ platform: 'darwin', exec, targetPath: '/tmp/nodeup' }); + } catch (e) { + threw = true; + } + assert.strictEqual(threw, false, 'must never throw — an install must not fail on this'); + assert.strictEqual(result, false); +}); + // Done. -process.stdout.write(`0 issues. (${passed} tests)\n`); +process.stdout.write(`0 issues. (${passed} tests)\n`); \ No newline at end of file