From 15334fac675613f775f24359b65703d690cb9c98 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 4 May 2026 10:08:58 -0700 Subject: [PATCH] Add SHA-256 checksum verification to menubar installer The installer now downloads and verifies a .sha256 companion file before extracting and launching the menubar app. Build script and CI workflow generate the checksum alongside the zip. Adds SECURITY.md with reporting instructions. Addresses #215. --- .github/workflows/release-menubar.yml | 4 ++- SECURITY.md | 21 +++++++++++ mac/Scripts/package-app.sh | 7 ++++ src/menubar-installer.ts | 52 +++++++++++++++++++++------ 4 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 SECURITY.md diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml index b3902d3..990d473 100644 --- a/.github/workflows/release-menubar.yml +++ b/.github/workflows/release-menubar.yml @@ -65,5 +65,7 @@ jobs: quarantine, and launches it. If you download the zip from this page directly and macOS shows "cannot verify developer", right-click the app in Finder and pick Open to whitelist it once. - files: mac/.build/dist/CodeBurnMenubar-*.zip + files: | + mac/.build/dist/CodeBurnMenubar-*.zip + mac/.build/dist/CodeBurnMenubar-*.zip.sha256 fail_on_unmatched_files: true diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c94d7f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities via [GitHub's private vulnerability reporting](https://github.com/getagentseal/codeburn/security/advisories/new). + +Do not open a public issue for security vulnerabilities. + +## Scope + +Security reports are welcome for: + +- The CLI (`src/`) +- The menubar installer (`src/menubar-installer.ts`) +- The macOS menubar app (`mac/`) +- The desktop app (`desktop/`) +- CI/CD workflows (`.github/workflows/`) + +## Release Integrity + +Menubar release assets include a `.sha256` checksum file. The installer verifies the checksum before extracting and launching the downloaded bundle. diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 5672b5e..5de94ed 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -98,6 +98,13 @@ ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" echo "▸ Packaging ${ZIP_NAME}..." (cd "${DIST_DIR}" && /usr/bin/ditto -c -k --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}") +CHECKSUM_NAME="${ZIP_NAME}.sha256" +CHECKSUM_PATH="${DIST_DIR}/${CHECKSUM_NAME}" +echo "▸ Computing SHA-256 checksum..." +(cd "${DIST_DIR}" && shasum -a 256 "${ZIP_NAME}" > "${CHECKSUM_NAME}") + echo "" echo "✓ Built ${ZIP_PATH}" +echo "✓ Checksum ${CHECKSUM_PATH}" +cat "${CHECKSUM_PATH}" ls -la "${DIST_DIR}" diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 3557141..b2ee90b 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import { createWriteStream } from 'node:fs' -import { mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' import { homedir, platform, tmpdir } from 'node:os' import { join } from 'node:path' import { pipeline } from 'node:stream/promises' @@ -11,6 +12,7 @@ import { Readable } from 'node:stream' const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest' const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/ +const CHECKSUM_PATTERN = /^CodeBurnMenubar-.*\.zip\.sha256$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' const MIN_MACOS_MAJOR = 14 @@ -19,6 +21,7 @@ export type InstallResult = { installedPath: string; launched: boolean } type ReleaseAsset = { name: string; browser_download_url: string } type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } +type ResolvedAssets = { zip: ReleaseAsset; checksum: ReleaseAsset | null } function userApplicationsDir(): string { return join(homedir(), 'Applications') @@ -57,10 +60,9 @@ async function sysProductVersion(): Promise { }) } -async function fetchLatestReleaseAsset(): Promise { +async function fetchLatestReleaseAssets(): Promise { const response = await fetch(RELEASE_API, { headers: { - // Identify the installer so GitHub's abuse heuristics treat us as a known client. 'User-Agent': 'codeburn-menubar-installer', Accept: 'application/vnd.github+json', }, @@ -69,14 +71,37 @@ async function fetchLatestReleaseAsset(): Promise { throw new Error(`GitHub release lookup failed: HTTP ${response.status}`) } const body = await response.json() as ReleaseResponse - const asset = body.assets.find(a => ASSET_PATTERN.test(a.name)) - if (!asset) { + const zip = body.assets.find(a => ASSET_PATTERN.test(a.name)) + if (!zip) { throw new Error( `No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` + `Check https://github.com/getagentseal/codeburn/releases.` ) } - return asset + const checksum = body.assets.find(a => CHECKSUM_PATTERN.test(a.name)) ?? null + return { zip, checksum } +} + +async function verifyChecksum(archivePath: string, checksumUrl: string): Promise { + const response = await fetch(checksumUrl, { + headers: { 'User-Agent': 'codeburn-menubar-installer' }, + redirect: 'follow', + }) + if (!response.ok) { + throw new Error(`Checksum download failed: HTTP ${response.status}`) + } + const text = await response.text() + const expected = text.trim().split(/\s+/)[0]!.toLowerCase() + const fileBytes = await readFile(archivePath) + const actual = createHash('sha256').update(fileBytes).digest('hex') + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${archivePath}.\n` + + ` Expected: ${expected}\n` + + ` Got: ${actual}\n` + + `The download may be corrupted or tampered with.` + ) + } } async function downloadToFile(url: string, destPath: string): Promise { @@ -134,13 +159,20 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom } console.log('Looking up the latest CodeBurn Menubar release...') - const asset = await fetchLatestReleaseAsset() + const { zip, checksum } = await fetchLatestReleaseAssets() const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) try { - const archivePath = join(stagingDir, asset.name) - console.log(`Downloading ${asset.name}...`) - await downloadToFile(asset.browser_download_url, archivePath) + const archivePath = join(stagingDir, zip.name) + console.log(`Downloading ${zip.name}...`) + await downloadToFile(zip.browser_download_url, archivePath) + + if (checksum) { + console.log('Verifying checksum...') + await verifyChecksum(archivePath, checksum.browser_download_url) + } else { + console.log('Warning: no checksum file found in release, skipping verification.') + } console.log('Unpacking...') await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir])