Skip to content
Merged
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
90 changes: 90 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: 🔒 Security

# Security checks for every pull request, main and a weekly run (new
# advisories appear without any code change). Policy, exception process and
# maintainer setup: docs/SECURITY-PIPELINE.md.
#
# dependency-policy bun audit on bun.lock + security/audit-exceptions.json.
# BLOCKING for unexcepted high/critical. No secrets.
# secrets gitleaks over the new commits (full history on main).
# BLOCKING. Values are redacted. No secrets.
# workflow-lint actionlint over .github/workflows. BLOCKING. No secrets.
#
# Snyk runs separately (snyk.yml). Deployment configuration is checked by
# src/lib/tests/deployConfig.test.ts and deploy-smoke.yml.

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "17 4 * * 1" # Mondays 04:17 UTC
workflow_dispatch:

permissions:
contents: read

concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true

jobs:
dependency-policy:
name: Dependency policy (bun.lock)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2

# bun audit reads bun.lock directly, so it checks exactly the versions
# the app is built with. Its own exit code only says "something was
# found"; the policy script decides what blocks.
- name: Audit and apply policy
run: |
bun audit --json > audit.json || true
node scripts/security/audit-policy.mjs security/audit-exceptions.json < audit.json

secrets:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Install gitleaks (pinned, checksum-verified)
env:
VERSION: 8.21.2
run: |
base="https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}"
file="gitleaks_${VERSION}_linux_x64.tar.gz"
curl -fsSLO "$base/$file"
curl -fsSLO "$base/gitleaks_${VERSION}_checksums.txt"
grep " ${file}\$" "gitleaks_${VERSION}_checksums.txt" | sha256sum -c -
tar -xzf "$file" gitleaks

# --redact keeps secret values out of the log; findings name the rule,
# file, line and commit.
- name: Scan
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$BASE" ]; then
./gitleaks git . --redact --no-banner --log-opts="$BASE..$HEAD"
else
./gitleaks git . --redact --no-banner
fi

workflow-lint:
name: Workflow lint (actionlint)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color
120 changes: 70 additions & 50 deletions bun.lock

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions docs/SECURITY-PIPELINE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Security pipeline

How Reframe checks dependencies, secrets and workflows, what blocks a
merge, and how exceptions are handled. Reporting a vulnerability is covered
by [SECURITY.md](../SECURITY.md).

## Checks

`.github/workflows/security.yml` runs on pull requests to `main`, on pushes
to `main`, weekly (Mondays 04:17 UTC) and on demand. It has `contents: read`
only and uses no secrets, so pull requests from forks are covered too.

| Job | What it checks | Blocks? |
|---|---|---|
| Dependency policy (bun.lock) | `bun audit` on `bun.lock` (the exact versions that are built), filtered through `security/audit-exceptions.json` | **yes**, on any high or critical advisory without a valid exception |
| Secret scan (gitleaks) | the pull request's new commits (the full history on `main`), values redacted | **yes** |
| Workflow lint (actionlint) | every workflow, with shellcheck on their scripts | **yes** |

Snyk runs separately in `snyk.yml`. Deployment configuration is checked by
`src/lib/tests/deployConfig.test.ts` and `deploy-smoke.yml`.

To make these merge requirements, a maintainer adds the three jobs as
required checks on `main` (a ruleset change).

## Dependency policy

- **High and critical** advisories block, unless `security/audit-exceptions.json`
has an exception for that exact package and advisory.
- **Moderate and low** advisories are listed and never block; review them in
the weekly run.
- **Every exception needs** a `reason` (why it isn't exploitable here, or why
it can't be fixed yet), the `reviewed` date and an `expires` date at most 90
days out. An expired exception blocks again, and one that no longer matches
anything is reported for removal.
- **Fixing:** prefer the smallest upgrade that leaves the vulnerable range,
within the existing semver ranges. A transitive package can be lifted with
an entry in `package.json` `overrides`. Take major upgrades in their own
pull request, with the full test suite. Keep peer-coupled packages on one
version: all `storybook`/`@storybook/*` packages, `next` with
`eslint-config-next`, `react` with `react-dom`.

Run it locally with `bun run audit`.

### Triage, 2026-11-06

`bun audit` on `main` before this pipeline: 1 critical and 20 high advisories,
all in development, test and build tooling (none ship to users).

| Package | Severity | Action |
|---|---|---|
| `storybook`, `@storybook/*` 10.5.10 | critical, high | **Fixed**: 10.6.6 (all Storybook packages together) |
| `vitest` 4.1.11, `vite` 8.2.2 | high, moderate | **Fixed**: vitest 4.1.12; vite 8.3.6 via `overrides` |
| `undici` 7.29.0 (jsdom), `zod` 4.5.4 (eslint-plugin-react-hooks), `source-map-js` 1.2.1 | high | **Fixed** within range via `overrides` |
| `image-size` (Storybook) | high | **Fixed** by the Storybook upgrade |
| `brace-expansion` 1.1.18 and 5.0.9 (ESLint's minimatch) | high | **Exception** until 2027-02-03: dev-only glob matching of repo-written patterns; two majors in use and bun has no nested overrides |
| `webpack-dev-middleware` 6.1.3 | high | **Exception** until 2027-02-03: Storybook's local dev server only; the fix is v7, which Storybook doesn't allow yet |

Runtime packages (`@ffmpeg/ffmpeg`, `@ffmpeg/util`, `@datadog/browser-rum*`,
`react`, `react-dom`, `next` and the rest) were left on their current
versions: none has a high or critical advisory.

After the fixes: 0 blocking, 5 excepted (the two packages above), 14
moderate or low.

## Secrets

On 2026-11-06, gitleaks 8.21.2 (redacted) found no secrets in the full
history (654 commits). If a scan ever finds a real secret: rotate it first,
then remove it from history with the maintainers' agreement, and never paste
the value into an issue or pull request.
19 changes: 12 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"chromatic": "chromatic --exit-zero-on-changes",
"audit": "bun audit --json | node scripts/security/audit-policy.mjs security/audit-exceptions.json",
"security": "npm install --package-lock-only --ignore-scripts --legacy-peer-deps && snyk test --severity-threshold=high --file=package-lock.json"
},
"dependencies": {
Expand All @@ -32,10 +33,10 @@
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@storybook/addon-a11y": "^10.5.10",
"@storybook/addon-docs": "^10.5.10",
"@storybook/addon-themes": "^10.5.10",
"@storybook/nextjs": "^10.5.10",
"@storybook/addon-a11y": "^10.6.6",
"@storybook/addon-docs": "^10.6.6",
"@storybook/addon-themes": "^10.6.6",
"@storybook/nextjs": "^10.6.6",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.4.3",
Expand All @@ -50,13 +51,17 @@
"jsdom": "^29.1.1",
"postcss": "^8",
"snyk": "^1.1307.0",
"storybook": "^10.5.10",
"storybook": "^10.6.6",
"tailwindcss": "^3.4.17",
"typescript": "^5",
"vitest": "^4.1.6"
"vitest": "^4.1.12"
},
"overrides": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5"
"@types/react-dom": "19.2.5",
"undici": "^7.30.1",
"vite": "^8.2.4",
"zod": "^4.6.6",
"source-map-js": "^1.2.2"
}
}
78 changes: 78 additions & 0 deletions scripts/security/audit-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Dependency gate for CI, reading bun.lock through `bun audit --json`.
// - blocks on any high or critical advisory without a valid exception;
// - lists moderate/low advisories as warnings (they never block);
// - an exception needs a reason and an expiry; expired ones block again;
// - exceptions that no longer match anything are reported, so the file
// doesn't accumulate dead entries.
// Usage: bun audit --json | node scripts/security/audit-policy.mjs [exceptions.json]
// Exit: 0 ok · 1 blocking advisories · 2 bad input. Policy: docs/SECURITY-PIPELINE.md.
import fs from "node:fs";

const BLOCKING = new Set(["critical", "high"]);
const MAX_EXCEPTION_DAYS = 90;

const advisoryId = (a) => (a.url || "").split("/").pop() || String(a.id);

/** Pure policy evaluation; `today` is an ISO date string. */
export function evaluate(audit, exceptions, today) {
const valid = new Map();
const problems = [];
for (const e of exceptions) {
const days = (Date.parse(e.expires) - Date.parse(today)) / 864e5;
if (!e.reason || !e.expires || Number.isNaN(days)) problems.push(`exception ${e.advisory}: needs a reason and an expiry`);
else if (days < 0) problems.push(`exception ${e.advisory} (${e.package}) expired on ${e.expires}; re-review it`);
else if (days > MAX_EXCEPTION_DAYS) problems.push(`exception ${e.advisory}: expiry more than ${MAX_EXCEPTION_DAYS} days out`);
else valid.set(`${e.package}|${e.advisory}`, e);
}

const blocking = [];
const excepted = [];
const warnings = [];
const matched = new Set();
for (const [pkg, list] of Object.entries(audit)) {
for (const adv of [].concat(list)) {
const id = advisoryId(adv);
const row = { package: pkg, id, severity: adv.severity, title: adv.title, vulnerable: adv.vulnerable_versions, url: adv.url };
if (!BLOCKING.has(adv.severity)) warnings.push(row);
else if (valid.has(`${pkg}|${id}`)) {
matched.add(`${pkg}|${id}`);
excepted.push({ ...row, reason: valid.get(`${pkg}|${id}`).reason });
} else blocking.push(row);
}
}
const unused = [...valid.keys()].filter((k) => !matched.has(k));
return { blocking, excepted, warnings, problems, unused, ok: blocking.length === 0 && problems.length === 0 };
}

function main() {
const exceptionsFile = process.argv[2] || "security/audit-exceptions.json";
let audit;
try {
const input = fs.readFileSync(0, "utf8").trim();
audit = input ? JSON.parse(input) : {};
} catch {
console.error("audit-policy: expected `bun audit --json` on stdin");
process.exit(2);
}
const { exceptions = [] } = JSON.parse(fs.readFileSync(exceptionsFile, "utf8"));
const result = evaluate(audit, exceptions, new Date().toISOString().slice(0, 10));

const line = (r) => ` ${r.severity.padEnd(8)} ${r.package} ${r.id} (${r.vulnerable})\n ${r.title}\n ${r.url}`;
if (result.blocking.length) {
console.log("BLOCKING — fix (upgrade out of the vulnerable range) or add a reviewed exception:");
result.blocking.forEach((r) => console.log(line(r)));
}
if (result.problems.length) {
console.log("Exception file problems:");
result.problems.forEach((p) => console.log(` ${p}`));
}
if (result.excepted.length) {
console.log("Excepted (reviewed, still tracked):");
result.excepted.forEach((r) => console.log(`${line(r)}\n reason: ${r.reason}`));
}
if (result.unused.length) console.log(`Exceptions that match nothing any more (remove them): ${result.unused.join(", ")}`);
console.log(`\n${result.blocking.length} blocking, ${result.excepted.length} excepted, ${result.warnings.length} moderate/low (not blocking).`);
process.exit(result.ok ? 0 : 1);
}

if (import.meta.url === `file://${process.argv[1]}`) main();
26 changes: 26 additions & 0 deletions security/audit-exceptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"$comment": "Reviewed exceptions for scripts/security/audit-policy.mjs. Each needs a reason, the reviewer's date and an expiry no more than 90 days out. An expired exception blocks CI again, forcing a re-review. See docs/SECURITY-PIPELINE.md.",
"exceptions": [
{
"advisory": "GHSA-g84c-rxfj-3j2c",
"package": "webpack-dev-middleware",
"reason": "Dev-only: Storybook's local dev server, never deployed. The fix is v7, but @storybook/builder-webpack5 10.6.x requires ^6.1.2. Revisit with the next Storybook minor.",
"reviewed": "2026-11-06",
"expires": "2027-02-03"
},
{
"advisory": "GHSA-6j4f-fj2g-mc7p",
"package": "brace-expansion",
"reason": "Dev-only: ESLint's glob matching (minimatch), which only expands patterns written in the repo's own config, so the recursion DoS needs a hostile pattern in our code. Fixed versions exist in both majors in use (1.x via minimatch 3, 5.x via typescript-estree), but bun has no nested overrides to pin both. Revisit when eslint and typescript-eslint update minimatch.",
"reviewed": "2026-11-06",
"expires": "2027-02-03"
},
{
"advisory": "GHSA-qhr7-859c-m2p7",
"package": "brace-expansion",
"reason": "Same as GHSA-6j4f-fj2g-mc7p: dev-only ESLint glob matching of repo-written patterns; both majors in use, no nested overrides in bun.",
"reviewed": "2026-11-06",
"expires": "2027-02-03"
}
]
}
42 changes: 42 additions & 0 deletions src/lib/tests/auditPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { evaluate } from "../../../scripts/security/audit-policy.mjs";

const adv = (severity: string, ghsa: string) => ({
severity,
url: `https://github.com/advisories/${ghsa}`,
title: `t-${ghsa}`,
vulnerable_versions: "<1.0.0",
});
const exception = (pkg: string, ghsa: string, expires = "2027-01-01") => ({ package: pkg, advisory: ghsa, reason: "dev-only", expires });

describe("dependency audit policy", () => {
it("blocks unexcepted high and critical advisories, never moderate or low", () => {
const r = evaluate({ a: [adv("critical", "GHSA-1"), adv("moderate", "GHSA-2")], b: adv("high", "GHSA-3"), c: adv("low", "GHSA-4") }, [], "2026-10-22");
expect(r.ok).toBe(false);
expect(r.blocking.map((x: { id: string }) => x.id)).toEqual(["GHSA-1", "GHSA-3"]);
expect(r.warnings).toHaveLength(2);
});

it("lets a reviewed, unexpired exception through but still lists it", () => {
const r = evaluate({ braces: [adv("high", "GHSA-x")] }, [exception("braces", "GHSA-x")], "2026-10-22");
expect(r.ok).toBe(true);
expect(r.excepted).toHaveLength(1);
});

it("only matches the exact package and advisory", () => {
const r = evaluate({ other: [adv("high", "GHSA-x")] }, [exception("braces", "GHSA-x")], "2026-10-22");
expect(r.ok).toBe(false);
expect(r.unused).toEqual(["braces|GHSA-x"]);
});

it("blocks again once an exception expires, and rejects open-ended ones", () => {
expect(evaluate({ braces: [adv("high", "GHSA-x")] }, [exception("braces", "GHSA-x", "2026-10-01")], "2026-10-22").ok).toBe(false);
expect(evaluate({}, [exception("braces", "GHSA-x", "2028-01-01")], "2026-10-22").problems[0]).toMatch(/more than 90 days/);
expect(evaluate({}, [{ package: "x", advisory: "y", expires: "2026-11-01" }], "2026-10-22").problems[0]).toMatch(/needs a reason/);
});

it("passes a clean audit", () => {
expect(evaluate({}, [], "2026-10-22")).toMatchObject({ ok: true, blocking: [], problems: [] });
});
});
3 changes: 2 additions & 1 deletion vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Vitest setup: polyfills and global mocks
import '@testing-library/jest-dom/vitest'

Object.defineProperty(window, 'matchMedia', {
// Node-environment tests (e.g. deployCheck.test.ts) have no window.
if (typeof window !== 'undefined') Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
Expand Down
Loading