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
67 changes: 56 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,6 @@ jobs:
- name: Verify endpoints
run: bun run verify

# Hard-gates on vulnerable direct/transitive deps. Two advisories are
# ignored because they're upstream-blocked (both via @lhci/cli@0.15.1
# and resend's transitive svix; both dev-/server-side with no
# exploitable code path) — see CLAUDE.md "Audit advisories" for
# context and removal triggers. Any new advisory fails the job.
- name: Dependency audit
run: bun audit --ignore=GHSA-w5hq-g745-h8pq --ignore=GHSA-52f5-9888-hmc6

# Playwright browsers are ~250 MB. Cache them keyed on the
# @playwright/test version pinned in package.json — invalidates on
# bumps, hits otherwise. System deps (apt packages) aren't covered by
Expand Down Expand Up @@ -152,11 +144,64 @@ jobs:
if: failure()
run: cat /tmp/server.log || true

# Reports dependency advisories WITHOUT gating merges. A new advisory can
# surface at any time from an upstream publish, with no change on our
# side, so a hard gate here blocks unrelated PRs on work we often cannot
# do (see CLAUDE.md "Audit advisories" for the upstream-blocked ones).
# `continue-on-error` on the step keeps the job green while still marking
# the step red and posting the full advisory list to the job summary, so
# the status stays visible on every run. Nothing is `--ignore`d: with no
# gate to keep green, suppressing advisories only hides information.
audit:
name: Dependency audit (non-blocking)
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.13

- name: Cache Bun install cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install
run: bun install --frozen-lockfile

- name: Dependency audit
continue-on-error: true
run: |
bun audit | tee /tmp/audit.log
status=${PIPESTATUS[0]}

{
echo "## Dependency audit"
echo
if [ "$status" -eq 0 ]; then
echo "No advisories reported."
else
echo "Advisories reported. Informational only — this does not block merge."
echo
echo '```'
cat /tmp/audit.log
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"

exit "$status"

# Runs only on PRs (no baseline diff to compute on a push to main).
# Compares the PR's dependency manifest against main and flags
# high-severity advisories or license incompatibilities. Hard-gated to
# match the `bun audit` posture: a PR introducing a new high-severity
# advisory must block merge, not just post a comment.
# high-severity advisories or license incompatibilities. Hard-gated: the
# `bun audit` job is informational, so this is the one advisory check
# that blocks merge, and it is scoped to what the PR itself introduces
# (severity `high` and above) rather than the whole pre-existing tree.
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22
24
37 changes: 27 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,25 +52,42 @@ Defense in depth against the npm-worm class (Shai-Hulud, mini-Shai-Hulud, the Ma
1. **`bunfig.toml` → `install.minimumReleaseAge = 1209600`** (14 days). Bun refuses to resolve to a package version younger than 14 days. Applies to every `bun install` / `bun add` / `bun update` (local dev, lockfile regens, and Vercel builds), so we can't accidentally pull a freshly published version that hasn't had time to be observed. If a fresh dep is genuinely needed before the window elapses, allowlist it via `install.minimumReleaseAgeExcludes`.
2. **`renovate.json` → `minimumReleaseAge: "14 days"`** — symmetric with #1, applied at PR-proposal time. Renovate won't open a PR for a version younger than the window, and `bunfig.toml` won't let Bun resolve to one either. Both gates are required: a local `bun add` bypasses Renovate; a Renovate `lockFileMaintenance` cycle would otherwise pull fresh transitives.
3. **`trustedDependencies` in `package.json`** — explicit allowlist for which packages may run lifecycle scripts (`preinstall` / `install` / `postinstall`). Bun's default behavior is name-only trust against a built-in ~366-package allowlist, which lets a transitive named like a popular package hijack scripts (the PackageGate class of attack). Current set: `["esbuild", "sharp"]` — esbuild's `postinstall` builds its native bin (pulled in transitively via velite); sharp's `install` builds libvips for next/image. When adding a top-level dep that ships native bins or needs a build step, audit its lifecycle scripts via `bun pm untrusted`, then extend this list with one-line justification in the commit message.
4. **`bun audit`** is the CI hard gate on every PR and push to main. When a new advisory surfaces, the resolution is one of three:
4. **`bun audit`** runs as its own **non-blocking** CI job (`audit`) on every PR and push to main. It reports the advisory state — the step goes red and the full output posts to the job summary — but the job stays green, so an upstream publish can't block unrelated PRs. Read the summary on every run and act on what it shows; the resolution is one of three:
- **Direct dep bump** — if the advisory is in a top-level dep with a patched release, bump in `package.json`.
- **`overrides` entry** — if the advisory is in a transitive dep whose parent hasn't released a fix, force-pin the patched version in `package.json`'s `overrides` block. This is the most common case. Pick the latest patched version that is ≥14 days old (matching the cooldown policy) so a fresh install can't resolve to a too-new version.
- **Document and ignore** — if no upstream fix exists yet, append `--ignore=GHSA-...` to the `Dependency audit` step in `.github/workflows/ci.yml` and add an entry under "Audit advisories" below with: GHSA ID, vulnerable range, package, why exposure is acceptable (e.g. dev-only, not in client bundle), and a removal trigger.
- **Document and accept** — if no upstream fix exists yet, add an entry under "Audit advisories" below with: GHSA ID, vulnerable range, package, why exposure is acceptable (e.g. dev-only, not in client bundle), and a removal trigger. The job carries no `--ignore` flags: with nothing to keep green, suppressing an advisory only hides it, so this file is where an accepted advisory is recorded.
- When a parent dep eventually patches its own transitive, drop the corresponding `overrides` entry — leaving stale overrides means we keep deduping a fix that was already merged upstream.
5. **`actions/dependency-review-action`** runs on every PR hard-fails the build if a PR introduces a new advisory at severity `high` or above. Catches what `bun audit` would catch on the merge commit, but earlier in the review loop.
5. **`actions/dependency-review-action`** runs on every PR and **hard-fails** the build if the PR introduces a new advisory at severity `high` or above. With `bun audit` informational, this is the only advisory check that blocks merge — deliberately scoped to what the PR itself adds, not the whole pre-existing tree.
6. **`trufflesecurity/trufflehog`** secret scan runs on every PR and push to main. Defense-in-depth on top of GitHub's push protection; catches verified secrets that slipped past push protection (low-entropy formats, detector patterns added after the secret was committed, or push-protection bypass).
7. **OpenSSF Scorecard** (`.github/workflows/scorecard.yml`) grades the repo weekly on supply-chain hygiene (pinned actions, branch protection, token permissions, dangerous workflow patterns). SARIF posts to the Security tab; aggregate score publishes to https://scorecard.dev.
8. **All third-party GitHub Actions are SHA-pinned**, not tag-pinned. A tag can be moved to point at a malicious commit (and has been, in prior supply-chain incidents); a SHA can't. Renovate's `helpers:pinGitHubActionDigests` preset enforces this on auto-bump PRs. When bumping an action manually, update both the SHA and the trailing `# vX.Y.Z` comment in the same diff.
9. **`runs-on: ubuntu-24.04`** (not `ubuntu-latest`) so runner-image bumps are deliberate PRs, not silent infrastructure drift. The Node version inside the runner image can still drift; `.nvmrc` pins that separately.
10. **Renovate auto-merge** is patch + minor only via `:automergeStableNonMajor`. Major bumps stay open for human review. The 14-day cooldown is the first gate; CI (lint, build, typecheck, `bun audit`, `dependency-review`, `secret-scan`, `bun run verify`, Playwright, Lighthouse) is the second.

### Node version

Three places must agree, and **Vercel sets the ceiling**: it offers only Node 20.x / 22.x / 24.x, so 24 is the newest deployable major. All three are aligned on it:

- `.nvmrc` → `24`, which both CI workflows read via `actions/setup-node`'s `node-version-file`.
- `package.json` → `engines.node = ">=24"`. Vercel resolves this range against the versions it actually offers and it **overrides the dashboard setting**, so a range no available version satisfies (e.g. `">=26"`) fails the deploy outright rather than falling back.
- `@types/node` → `^24`, matching the runtime so `tsc` cannot green-light an API that production does not have.

Keep them in lockstep when bumping. Raising `.nvmrc` alone makes CI validate against a runtime newer than production — APIs added after 24 would pass CI and fail in prod. Node 26 is not deployable until Vercel adds 26.x (it reaches LTS in October 2026); check https://vercel.com/docs/functions/runtimes/node-js/node-js-versions before moving.
10. **Renovate auto-merge** is patch + minor only via `:automergeStableNonMajor`. Major bumps stay open for human review. The 14-day cooldown is the first gate; CI (lint, build, typecheck, `dependency-review`, `secret-scan`, `bun run verify`, Playwright, Lighthouse) is the second — note `bun audit` is informational and does not hold an auto-merge.

### Audit advisories

`bun audit` is the source of truth for dependency advisories. State as of 2026-05-04:
`bun audit` is the source of truth for dependency advisories. State as of 2026-08-27 — 4 advisories, all dev-only:

**Resolved by an `overrides` entry:**

- **sharp `<0.35.0`** (GHSA-f88m-g3jw-g9cj, high — inherited libvips CVE-2026-33327 / -33328 / -35590 / -35591). `next@16.3.0` already asks for `sharp@^0.35.3`, but `velite@0.4.0` declares `sharp@^0.34.5`, a range that cannot reach 0.35.x — so the tree carried a vulnerable second copy at 0.34.5. Resolved via `overrides.sharp = "^0.35.3"`, which pulls velite onto the patched line and dedupes to a single copy. Crossing 0.34 → 0.35 is a breaking bump on sharp's 0.x line; verified safe here because velite's image pipeline runs during `bun run build` and the build passes. Drop the override once velite ships a release declaring `sharp@^0.35.0` or later — 0.4.0 did not, so this is likely to persist for a while.

**Accepted — upstream-blocked, every path is via `@lhci/cli@0.15.1`, which is dev-only and runs in CI against its own controlled inputs:**

- **extract-zip `<=2.0.1`** (GHSA-jmr9-qjv8-65gv, high unvalidated symlink path traversal). Path: `@lhci/cli > lighthouse > puppeteer-core > @puppeteer/browsers > extract-zip`. No patched version exists — 2.0.1 is the latest publish, so there is nothing to override to. The archives extracted are Chrome builds fetched by puppeteer from Google's own CDN, not attacker-supplied. Drop this entry when `extract-zip` ships a fix or `lighthouse` moves off it.
- **tmp `<0.2.6`** (GHSA-ph9p-34f9-6g65, high path traversal via unsanitized prefix/postfix) and **tmp `<=0.2.3`** (GHSA-52f5-9888-hmc6, low symlink write via the `dir` param). `@lhci/cli` pulls `tmp@0.1.0` and `tmp@0.0.33`. Overriding to `^0.2.6` crosses tmp's 0.1 → 0.2 API break (the sync/callback surface changed) inside a tool we cannot test beyond running it, so the override is not worth the risk for a CI-only dev dep. Neither traversal scenario applies: nothing passes user input to `tmp`'s prefix, postfix, or `dir`. Drop this entry when `@lhci/cli` ships a release with patched transitives.
- **uuid `<11.1.1`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` is provided). Now a single path, `@lhci/cli > uuid@8.3.2` — the former `resend > svix > uuid` path cleared when resend went to 6.19.0, so this is no longer a production concern. The vulnerable code (v3/v5/v6 called with an explicit `buf`) is not reached. Drop this entry when `@lhci/cli` bumps uuid.

- **postcss `<8.5.10`** (GHSA-qx2v-qp2m-jg93, moderate XSS in CSS stringify). Multiple transitive resolutions — `next@16.2.4` pins `postcss@8.4.31` exactly, and `@tailwindcss/postcss@4.2.3` brings in `postcss@^8.5.6`. Resolved via `overrides.postcss = "8.5.10"` in `package.json`, which dedupes all transitives to the patched version. Drop the override after `next` and `@tailwindcss/postcss` ship releases that pull their transitives to ≥ 8.5.10.
- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by two independent paths (shiki/rehype-pretty-code, velite/@mdx-js/mdx) — both parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1.
- **uuid `<14.0.0`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` provided). **Upstream-blocked.** Two parent paths: `resend@6.12.2 → svix@1.90.0 → uuid@^10.0.0` and `@lhci/cli@0.15.1 → uuid@8.3.2`. Neither parent admits a 14.x override without risking CJS imports. Exposure is theoretical on both: `/api/subscribe` uses Resend's send-email endpoint (not svix's webhook-signing path), `@lhci/cli` is dev-only and runs in CI on its own controlled inputs, and the vulnerable code (v3/v5/v6 with explicit `buf`) isn't called by either. Remove the `--ignore` when both parents ship releases bumping uuid to `^14.0.0`.
- **tmp `<=0.2.3`** (GHSA-52f5-9888-hmc6, low symbolic-link path traversal in `dir` param). **Upstream-blocked.** Pulled exclusively by `@lhci/cli@0.15.1` (dev-only, runs in CI on controlled inputs). The symlink-traversal scenario doesn't apply. Remove the `--ignore` when `@lhci/cli` ships a release with patched transitives.
**Cleared, for the record:** the `postcss` override (GHSA-qx2v-qp2m-jg93) is gone — `next@16.3.0` pins `postcss@8.5.23` exactly and `@tailwindcss/postcss@4.3.3` resolves `^8.5.16` to 8.5.26, both well past the 8.5.10 floor, so the old `8.5.13` pin had become a *downgrade* of next's own exact pin. Two postcss copies now coexist because next pins exactly; deduping them would mean forcing next off that pin, and postcss is build-time only (never in the client bundle), so the duplicate is left alone. The `mdast-util-to-hast` override (GHSA-4fh9-h7wg-q85m) is also gone — its parents still declare `^13.0.0`, but that range now resolves to 13.2.1 on its own, making the floor guard a no-op.

CI hard-gates on `bun audit` (`.github/workflows/ci.yml`) with `--ignore=GHSA-w5hq-g745-h8pq` and `--ignore=GHSA-52f5-9888-hmc6` for the upstream-blocked advisories. Any new advisory fails the job.
The `audit` job in `.github/workflows/ci.yml` runs a bare `bun audit` and never fails CI; the advisories above (including the two upstream-blocked ones) show in its job summary until their removal triggers land. Merge blocking on advisories is `dependency-review`'s job.
7 changes: 4 additions & 3 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand All @@ -17,7 +17,8 @@
"!out",
"!build",
"!next-env.d.ts",
"!src/app/globals.css"
"!src/app/globals.css",
"!**/*.svg"
]
},
"formatter": {
Expand Down Expand Up @@ -48,7 +49,7 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"preset": "recommended",
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
Expand Down
Loading
Loading