Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches:
- main

permissions:
contents: read

concurrency:
group: main-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: CI
on:
pull_request:

permissions:
contents: read

concurrency:
group: pr-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release-prebuilt-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ on:
tags:
- "v*"

permissions:
contents: read

concurrency:
group: release-prebuilt-${{ github.ref }}
cancel-in-progress: false
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <paths>`.
- 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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
132 changes: 132 additions & 0 deletions scripts/check-security-audit.ts
Original file line number Diff line number Diff line change
@@ -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/[email protected]; Hunk and @pierre/diffs already use [email protected].",
},
{
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<AuditFinding, "packageName" | "id">) {
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<string, RawAuditEntry[]>;

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();
}
66 changes: 66 additions & 0 deletions test/security-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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]!,
]);
});
});
Loading