From f39d8742c312b9fd64aa3495db1d10b85f1c6c45 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Fri, 16 Oct 2026 17:03:36 -0400 Subject: [PATCH] build: harden packaged builds with Electron fuses Pin the hardening fuse set in electron-builder.js so packaged builds refuse ELECTRON_RUN_AS_NODE, NODE_OPTIONS and the Node inspector, encrypt the cookie store, load only from app.asar, and validate that archive before loading it. Because electron-builder leaves the Electron defaults in place when electronFuses is absent, scripts/verifyFuses.js reads the fuse wire back out of each built artifact and fails when it does not match the expected posture. The expected set is a constant in that script rather than read from the configuration it polices, which would otherwise pass vacuously once the configuration is removed. Both workflows run it after packaging. resetAdHocDarwinSignature is also set: flipping the fuses invalidates the ad-hoc signature Electron ships with, and build.yml packages macOS unsigned, so the arm64 artifact is killed on launch without it. Supersedes #3423, which reaches the same outcome by flipping fuses in scripts/afterPack.js. The configuration route needs no new dependency and resolves each platform's executable itself. --- .github/workflows/build.yml | 6 + .github/workflows/publish.yml | 6 + CONTRIBUTING.md | 24 ++++ electron-builder.js | 27 +++++ scripts/afterPack.js | 23 ++++ scripts/verifyFuses.js | 215 ++++++++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+) create mode 100644 scripts/verifyFuses.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e6edc180b..fbbd0517e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,16 +29,19 @@ jobs: platform: macOS package-cmd: vp run package:macos --publish=never -c.mac.identity=null cleanup-path: dist/mac-universal + fuse-path: dist/mac-universal/Gitify.app artifact-name: Gitify-dist-mac - os: windows-latest platform: Windows package-cmd: vp run package:win --publish=never cleanup-path: dist/win-unpacked + fuse-path: dist/win-unpacked/Gitify.exe artifact-name: Gitify-dist-win - os: ubuntu-latest platform: Linux package-cmd: vp run package:linux --publish=never cleanup-path: dist/linux-unpacked + fuse-path: dist/linux-unpacked/gitify artifact-name: Gitify-dist-linux runs-on: ${{ matrix.os }} @@ -63,6 +66,9 @@ jobs: CSC_LINK: ${{ matrix.platform == 'macOS' && secrets.CSC_LINK || (matrix.platform == 'windows' && secrets.WIN_CSC_LINK || '') }} CSC_KEY_PASSWORD: ${{ matrix.platform == 'macOS' && secrets.CSC_KEY_PASSWORD || (matrix.platform == 'windows' && secrets.WIN_CSC_KEY_PASSWORD || '') }} + - name: Verify Electron fuses + run: node scripts/verifyFuses.js "${{ matrix.fuse-path }}" + - name: Clean up builds shell: bash run: rm -rfv ${{ matrix.cleanup-path }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 67931f854..702b3ce65 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,16 +46,19 @@ jobs: - os: macos-latest platform: macOS package-cmd: vp run package:macos --publish onTagOrDraft + fuse-path: dist/mac-universal/Gitify.app artifact-name: Gitify-release-mac use-apple-notarization: true - os: windows-latest platform: Windows package-cmd: vp run package:win --publish onTagOrDraft + fuse-path: dist/win-unpacked/Gitify.exe artifact-name: Gitify-release-win use-apple-notarization: false - os: ubuntu-latest platform: Linux package-cmd: vp run package:linux --publish onTagOrDraft + fuse-path: dist/linux-unpacked/gitify artifact-name: Gitify-release-linux use-apple-notarization: false runs-on: ${{ matrix.os }} @@ -91,6 +94,9 @@ jobs: # General GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Verify Electron fuses + run: node scripts/verifyFuses.js "${{ matrix.fuse-path }}" + - name: Upload artifacts uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aea7fb27a..e82bb9e6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,29 @@ No manual milestone creation, renaming, or closing is required. - Pull request notification uses `issues: write`, `pull-requests: write`, and `contents: read`. - The validation jobs (`lint`, `tests`) remain read-only. +#### 🔒 Packaged build hardening + +Packaged builds pin Electron's [fuses][electron-fuses] in `electron-builder.js`, so a shipped binary refuses the framework's permissive defaults. electron-builder flips them after packing and before signing, so the code signature covers the flipped binary. + +| Fuse | Value | Effect | +| --------------------------------------- | ----- | ---------------------------------------------------------------------- | +| `runAsNode` | off | refuses `ELECTRON_RUN_AS_NODE`, so the binary cannot run as plain Node | +| `enableCookieEncryption` | on | encrypts the Chromium cookie store at rest | +| `enableNodeOptionsEnvironmentVariable` | off | ignores `NODE_OPTIONS` and `NODE_EXTRA_CA_CERTS` | +| `enableNodeCliInspectArguments` | off | refuses `--inspect` and `SIGUSR1` | +| `enableEmbeddedAsarIntegrityValidation` | on | validates `app.asar` before loading it | +| `onlyLoadAppFromAsar` | on | no fallback to an adjacent `app/` directory | + +`electronFuses` also sets `resetAdHocDarwinSignature`, which is not a fuse. Flipping the fuses invalidates the ad-hoc signature Electron ships with, and Apple Silicon refuses to launch a binary whose signature no longer validates. The build workflow packages macOS unsigned (`-c.mac.identity=null`), so without it the arm64 artifact is killed on launch. Signed builds re-sign afterwards, superseding the ad-hoc signature. + +Three settings are deliberate non-choices: + +- **`grantFileProtocolExtraPrivileges` stays at its Electron default.** The renderer loads `index.html` over `file://`, so removing those privileges breaks asset and sound loading. +- **`asar` must stay enabled.** `onlyLoadAppFromAsar` requires it, and packaging without an archive fails rather than producing an artifact that cannot start. +- **Asar integrity is not required on Linux.** Electron does not support it there, so the fuse is inert. + +`scripts/verifyFuses.js` reads the fuse wire back out of the built artifacts and fails when it does not match the expected posture. The expected set is a constant in that script rather than read from this configuration, so removing the configuration fails the build instead of passing vacuously. The build and publish workflows run it after packaging, because a missing `electronFuses` block is otherwise a silent no-op: electron-builder simply leaves the Electron defaults in place. + ### 🖌️ Design Guidelines 1. Use sentence case where possible @@ -157,6 +180,7 @@ Currently supported forges: **GitHub** (Cloud, Enterprise Server, Enterprise Clo [vite-plus-website]: https://viteplus.dev/ +[electron-fuses]: https://www.electronjs.org/docs/latest/tutorial/fuses [conventional-commits]: https://www.conventionalcommits.org [release-please]: https://github.com/googleapis/release-please [github-dependency-dashboard]: https://github.com/gitify-app/gitify/issues/576 diff --git a/electron-builder.js b/electron-builder.js index dc20ce5d4..93b795b63 100644 --- a/electron-builder.js +++ b/electron-builder.js @@ -6,6 +6,33 @@ const config = { appId: 'com.electron.gitify', copyright: 'Copyright © 2026 Gitify Team', asar: true, + /** + * Hardening switches baked into the packaged Electron binary. electron-builder + * flips these after packing and before signing, so the code signature covers the + * flipped binary. Left unset, every switch keeps its permissive Electron default. + * + * `scripts/verifyFuses.js` asserts these values against the built artifacts. + */ + electronFuses: { + runAsNode: false, // refuses ELECTRON_RUN_AS_NODE + enableCookieEncryption: true, // encrypts the Chromium cookie store at rest + enableNodeOptionsEnvironmentVariable: false, // ignores NODE_OPTIONS / NODE_EXTRA_CA_CERTS + enableNodeCliInspectArguments: false, // refuses --inspect and SIGUSR1 + enableEmbeddedAsarIntegrityValidation: true, // validates app.asar before loading it + onlyLoadAppFromAsar: true, // no fallback to an adjacent app/ directory + /** + * Not a fuse: re-applies the ad-hoc macOS signature after flipping. Flipping + * invalidates the ad-hoc signature Electron ships with, and Apple Silicon + * refuses to launch a binary whose signature no longer validates. The build + * workflow packages macOS unsigned (`-c.mac.identity=null`), so without this + * the arm64 artifact is killed on launch. Signing builds re-sign afterwards, + * which supersedes the ad-hoc signature. + */ + resetAdHocDarwinSignature: true, + // grantFileProtocolExtraPrivileges is deliberately left at its Electron default: + // the renderer loads index.html over file://, so removing those privileges + // breaks asset and sound loading. + }, files: [ 'assets/images/*', 'assets/sounds/*', diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 4a7f82d45..33e6b9c6b 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -9,6 +9,27 @@ function logAfterPackProgress(msg) { console.log(` • [afterPack]: ${msg}`); } +/** + * Fails packaging when the application archive is missing. + * + * The `onlyLoadAppFromAsar` fuse stops Electron falling back to an adjacent + * `app` directory, so a build without `app.asar` cannot start. Catching it here + * fails the build instead of shipping an artifact that will not launch. + * + * @param {import('electron-builder').AfterPackContext} context + */ +const assertApplicationArchive = (context) => { + const archivePath = path.join(context.packager.getResourcesDir(context.appOutDir), 'app.asar'); + + if (!fs.existsSync(archivePath)) { + throw new Error( + `no application archive at ${archivePath}: asar must stay enabled because the onlyLoadAppFromAsar fuse requires it`, + ); + } + + logAfterPackProgress(`verified application archive: ${archivePath}`); +}; + /** * @param {import('electron-builder').AfterPackContext} context */ @@ -23,6 +44,8 @@ const afterPack = async (context) => { removeUnusedLocales(appOutDir, appName); } + assertApplicationArchive(context); + logAfterPackProgress('Completed'); }; diff --git a/scripts/verifyFuses.js b/scripts/verifyFuses.js new file mode 100644 index 000000000..69eb6bac9 --- /dev/null +++ b/scripts/verifyFuses.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/** + * Asserts that packaged artifacts carry the hardened Electron fuse state. + * + * electron-builder silently leaves the Electron defaults in place when + * `electronFuses` is absent, so a removed or bypassed configuration would ship + * an unhardened build on a green pipeline. This reads the fuse wire back out of + * each built artifact and fails when it does not match the expected posture. + * + * The expected posture is a constant here rather than read from + * `electron-builder.js`. A check that derives its expectations from the + * configuration it is meant to police passes vacuously once that configuration + * is removed, which is the exact failure this guards against. Deliberately + * weakening the posture therefore means editing this file too. + * + * The wire format is documented at + * https://www.electronjs.org/docs/latest/tutorial/fuses: a sentinel string, + * then the wire version, then the wire length, then one byte per fuse. It is + * decoded here rather than imported from `@electron/fuses`, which is only a + * transitive dependency of electron-builder and is not resolvable from the + * project root under pnpm's isolated node_modules layout. + * + * Usage: + * node scripts/verifyFuses.js [ ...] + * + * Each artifact is a packaged macOS app bundle or a Windows/Linux executable. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const SENTINEL = Buffer.from('dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX', 'ascii'); +const FUSE_ENABLE = 49; // '1' +const FUSE_DISABLE = 48; // '0' + +/** + * The hardened posture every packaged artifact must carry. See the header note + * for why this is not read from `electron-builder.js`. + */ +const EXPECTED_FUSES = { + runAsNode: false, + enableCookieEncryption: true, + enableNodeOptionsEnvironmentVariable: false, + enableNodeCliInspectArguments: false, + enableEmbeddedAsarIntegrityValidation: true, + onlyLoadAppFromAsar: true, +}; + +/** + * Fuse positions within the wire, in the order `@electron/fuses` defines them. + * See `FuseV1Options` in @electron/fuses. + */ +const FUSE_WIRE_ORDER = [ + 'runAsNode', + 'enableCookieEncryption', + 'enableNodeOptionsEnvironmentVariable', + 'enableNodeCliInspectArguments', + 'enableEmbeddedAsarIntegrityValidation', + 'onlyLoadAppFromAsar', + 'loadBrowserProcessSpecificV8Snapshot', + 'grantFileProtocolExtraPrivileges', +]; + +/** + * Mirrors `pathToFuseFile` in @electron/fuses: on macOS the fuse wire lives in + * the Electron Framework binary, not in the app executable. + * + * @param {string} artifactPath + * @returns {string} + */ +const resolveFuseFile = (artifactPath) => { + if (artifactPath.endsWith('.app')) { + return path.join( + artifactPath, + 'Contents', + 'Frameworks', + 'Electron Framework.framework', + 'Electron Framework', + ); + } + + if (artifactPath.includes('.app')) { + return path.resolve( + artifactPath, + '..', + '..', + 'Frameworks', + 'Electron Framework.framework', + 'Electron Framework', + ); + } + + return artifactPath; +}; + +/** + * @param {string} artifactPath + * @returns {string} + */ +const describePlatform = (artifactPath) => { + if (artifactPath.endsWith('.app')) { + return 'macOS'; + } + + if (artifactPath.endsWith('.exe')) { + return 'Windows'; + } + + return 'Linux'; +}; + +/** + * Reads every fuse wire in a binary. Universal builds carry one wire per slice, + * and each slice must be flipped. + * + * @param {string} fuseFilePath + * @returns {Array<{ version: number, states: Buffer }>} + */ +const readFuseWires = (fuseFilePath) => { + const binary = fs.readFileSync(fuseFilePath); + const positions = []; + let index = binary.indexOf(SENTINEL); + + while (index !== -1) { + positions.push(index); + index = binary.indexOf(SENTINEL, index + 1); + } + + if (positions.length === 0) { + throw new Error( + `no fuse wire sentinel in ${fuseFilePath}: fuses require Electron 12 or higher`, + ); + } + + return positions.map((position) => { + const offset = position + SENTINEL.length; + + return { + version: binary[offset], + states: binary.subarray(offset + 2, offset + 2 + binary[offset + 1]), + }; + }); +}; + +/** + * @param {string} artifactPath + * @returns {string[]} failure descriptions, empty when the artifact is hardened + */ +const collectFailures = (artifactPath) => { + const fuseFilePath = resolveFuseFile(artifactPath); + + if (!fs.existsSync(fuseFilePath)) { + return [`no fuse file at ${fuseFilePath}`]; + } + + const wires = readFuseWires(fuseFilePath); + const failures = []; + + for (const [wireIndex, wire] of wires.entries()) { + for (const [name, enabled] of Object.entries(EXPECTED_FUSES)) { + const position = FUSE_WIRE_ORDER.indexOf(name); + + if (position === -1) { + failures.push(`${name} is not a fuse this checker knows about`); + continue; + } + + if (position >= wire.states.length) { + failures.push( + `${name} is beyond the ${wire.states.length}-entry wire in this Electron build`, + ); + continue; + } + + const actual = wire.states[position]; + const expected = enabled ? FUSE_ENABLE : FUSE_DISABLE; + + if (actual !== expected) { + failures.push( + `${name} is ${actual === FUSE_ENABLE ? 'enabled' : 'disabled'} but should be ${enabled ? 'enabled' : 'disabled'} (slice ${wireIndex + 1} of ${wires.length})`, + ); + } + } + } + + return failures; +}; + +const artifacts = process.argv.slice(2); + +if (artifacts.length === 0) { + console.error('usage: node scripts/verifyFuses.js [ ...]'); + process.exit(2); +} + +let hasFailures = false; + +for (const artifactPath of artifacts) { + const platform = describePlatform(artifactPath); + const failures = collectFailures(artifactPath); + + if (failures.length === 0) { + console.log(` • [verifyFuses] ${platform}: ${artifactPath} matches the hardened fuse posture`); + continue; + } + + hasFailures = true; + console.error(` • [verifyFuses] ${platform}: ${artifactPath} is not hardened`); + + for (const failure of failures) { + console.error(` - ${failure}`); + } +} + +process.exit(hasFailures ? 1 : 0);