Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -157,6 +180,7 @@ Currently supported forges: **GitHub** (Cloud, Enterprise Server, Enterprise Clo
<!-- LINK LABELS -->

[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
Expand Down
27 changes: 27 additions & 0 deletions electron-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/*',
Expand Down
23 changes: 23 additions & 0 deletions scripts/afterPack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -23,6 +44,8 @@ const afterPack = async (context) => {
removeUnusedLocales(appOutDir, appName);
}

assertApplicationArchive(context);

logAfterPackProgress('Completed');
};

Expand Down
215 changes: 215 additions & 0 deletions scripts/verifyFuses.js
Original file line number Diff line number Diff line change
@@ -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 <artifact> [<artifact> ...]
*
* 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 <artifact> [<artifact> ...]');
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);
Loading