From af9bef38be9e67a060a060db48fe7a1b4e4c3ef7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 22 Mar 2026 13:58:25 -0400 Subject: [PATCH] ci: add security review and audit checks --- .github/workflows/ci.yml | 3 + .github/workflows/pr-ci.yml | 3 + .github/workflows/release-prebuilt-npm.yml | 3 + .github/workflows/security.yml | 46 +++++++ CONTRIBUTING.md | 7 ++ package.json | 1 + scripts/check-security-audit.ts | 132 +++++++++++++++++++++ test/security-audit.test.ts | 66 +++++++++++ 8 files changed, 261 insertions(+) create mode 100644 .github/workflows/security.yml create mode 100644 scripts/check-security-audit.ts create mode 100644 test/security-audit.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c0bca2..96e8be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: read + concurrency: group: main-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 18c0ea2..f05a6fe 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -3,6 +3,9 @@ name: CI on: pull_request: +permissions: + contents: read + concurrency: group: pr-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml index 97c29c1..c0ce1ef 100644 --- a/.github/workflows/release-prebuilt-npm.yml +++ b/.github/workflows/release-prebuilt-npm.yml @@ -17,6 +17,9 @@ on: tags: - "v*" +permissions: + contents: read + concurrency: group: release-prebuilt-${{ github.ref }} cancel-in-progress: false diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..8082eab --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,46 @@ +name: Security + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + + audit: + name: Audit dependency tree + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check audited findings against the allowlist + run: bun run check:security-audit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a56a8d9..c6c3012 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,12 @@ bun run build:npm bun run check:pack ``` +Run the security audit check with the current allowlist: + +```bash +bun run check:security-audit +``` + Build and smoke-test the prebuilt npm packages for the current host: ```bash @@ -90,6 +96,7 @@ Key rules: - Update docs and examples when behavior or workflows change. - If you change this repo locally, refresh `.hunk/latest.json` for review, but do not commit it. - If newly created files should appear in `hunk diff` before commit, use `git add -N `. +- Dependency review and the security audit workflow should stay low-noise. If a finding is real and currently unavoidable, update the audit allowlist with a short rationale instead of silently ignoring it. ## Release notes diff --git a/package.json b/package.json index 42a45d2..f3c008b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 bun test test/tty-render-smoke.test.ts", "check:pack": "bun run ./scripts/check-pack.ts", "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", + "check:security-audit": "bun run ./scripts/check-security-audit.ts", "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts", "prepack": "bun run build:npm", diff --git a/scripts/check-security-audit.ts b/scripts/check-security-audit.ts new file mode 100644 index 0000000..1fa8006 --- /dev/null +++ b/scripts/check-security-audit.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env bun + +interface RawAuditEntry { + id: number; + url: string; + title: string; + severity: string; + vulnerable_versions?: string; +} + +interface AllowedAuditFinding { + packageName: string; + id: number; + note: string; +} + +export interface AuditFinding { + packageName: string; + id: number; + url: string; + title: string; + severity: string; + vulnerableVersions?: string; +} + +export const ALLOWED_AUDIT_FINDINGS: AllowedAuditFinding[] = [ + { + packageName: "diff", + id: 1112706, + note: "Transitive via @opentui/core@0.1.88; Hunk and @pierre/diffs already use diff@8.0.3.", + }, + { + packageName: "file-type", + id: 1114301, + note: "Transitive via @opentui/core -> jimp; keep watching upstream updates.", + }, +]; + +function stripAnsi(text: string) { + return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\x1b[@-_]/g, ""); +} + +function findingKey(finding: Pick) { + return `${finding.packageName}:${finding.id}`; +} + +/** Parse Bun's audit JSON output even when the CLI prefixes it with banner text. */ +export function parseBunAuditJson(output: string): AuditFinding[] { + const normalized = stripAnsi(output).trim(); + const jsonStart = normalized.indexOf("{"); + if (jsonStart < 0) { + throw new Error(`Could not find JSON in bun audit output. Full output:\n${output}`); + } + + const parsed = JSON.parse(normalized.slice(jsonStart)) as Record; + + return Object.entries(parsed).flatMap(([packageName, advisories]) => + (advisories ?? []).map((advisory) => ({ + packageName, + id: advisory.id, + url: advisory.url, + title: advisory.title, + severity: advisory.severity, + vulnerableVersions: advisory.vulnerable_versions, + })), + ); +} + +export function evaluateAuditFindings( + findings: AuditFinding[], + allowlist: AllowedAuditFinding[] = ALLOWED_AUDIT_FINDINGS, +) { + const findingKeys = new Set(findings.map((finding) => findingKey(finding))); + const allowlistKeys = new Set(allowlist.map((finding) => findingKey(finding))); + + return { + unexpectedFindings: findings.filter((finding) => !allowlistKeys.has(findingKey(finding))), + staleAllowlistEntries: allowlist.filter((finding) => !findingKeys.has(findingKey(finding))), + }; +} + +function renderFinding(finding: AuditFinding) { + return `- ${finding.packageName}#${finding.id} [${finding.severity}] ${finding.title} (${finding.url})`; +} + +function renderAllowedEntry(entry: AllowedAuditFinding) { + return `- ${entry.packageName}#${entry.id} — ${entry.note}`; +} + +async function main() { + const proc = Bun.spawnSync([process.execPath, "audit", "--json"], { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8").trim(); + + if (proc.exitCode !== 0 && stdout.trim().length === 0) { + throw new Error(stderr || "bun audit failed before it could produce JSON output."); + } + + const findings = parseBunAuditJson(stdout); + const { unexpectedFindings, staleAllowlistEntries } = evaluateAuditFindings(findings); + + if (unexpectedFindings.length > 0 || staleAllowlistEntries.length > 0) { + const sections = [ + unexpectedFindings.length > 0 + ? ["Unexpected bun audit findings:", ...unexpectedFindings.map(renderFinding)].join("\n") + : null, + staleAllowlistEntries.length > 0 + ? ["Stale audit allowlist entries:", ...staleAllowlistEntries.map(renderAllowedEntry)].join("\n") + : null, + ].filter((section): section is string => Boolean(section)); + + throw new Error(sections.join("\n\n")); + } + + console.log(`Security audit check passed with ${findings.length} known finding(s) still allowlisted.`); + if (findings.length > 0) { + for (const finding of findings) { + console.log(renderFinding(finding)); + } + } +} + +if (import.meta.main) { + await main(); +} diff --git a/test/security-audit.test.ts b/test/security-audit.test.ts new file mode 100644 index 0000000..1343ac3 --- /dev/null +++ b/test/security-audit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { ALLOWED_AUDIT_FINDINGS, evaluateAuditFindings, parseBunAuditJson } from "../scripts/check-security-audit"; + +describe("security audit helpers", () => { + test("parseBunAuditJson tolerates Bun's banner text", () => { + const findings = parseBunAuditJson( + "\u001b[0m\u001b[1mbun audit \u001b[0m\u001b[2mv1.3.10\u001b[0m\n" + + JSON.stringify({ + diff: [ + { + id: 1112706, + url: "https://github.com/advisories/GHSA-73rr-hh4g-fpgx", + title: "jsdiff has a Denial of Service vulnerability in parsePatch and applyPatch", + severity: "low", + vulnerable_versions: ">=6.0.0 <8.0.3", + }, + ], + }), + ); + + expect(findings).toEqual([ + { + packageName: "diff", + id: 1112706, + url: "https://github.com/advisories/GHSA-73rr-hh4g-fpgx", + title: "jsdiff has a Denial of Service vulnerability in parsePatch and applyPatch", + severity: "low", + vulnerableVersions: ">=6.0.0 <8.0.3", + }, + ]); + }); + + test("evaluateAuditFindings reports both unexpected findings and stale allowlist entries", () => { + const findings = [ + { + packageName: "diff", + id: 1112706, + url: "https://github.com/advisories/GHSA-73rr-hh4g-fpgx", + title: "Known diff advisory", + severity: "low", + }, + { + packageName: "new-package", + id: 999999, + url: "https://github.com/advisories/example", + title: "Unexpected advisory", + severity: "high", + }, + ]; + + const result = evaluateAuditFindings(findings, ALLOWED_AUDIT_FINDINGS); + + expect(result.unexpectedFindings).toEqual([ + { + packageName: "new-package", + id: 999999, + url: "https://github.com/advisories/example", + title: "Unexpected advisory", + severity: "high", + }, + ]); + expect(result.staleAllowlistEntries).toEqual([ + ALLOWED_AUDIT_FINDINGS[1]!, + ]); + }); +});