Skip to content

fix(viewer): make the frontend build hermetic and actually verify dist/ - #66

Merged
rdasilveiracabral merged 1 commit into
mainfrom
harden-frontend-build-verification
Aug 1, 2026
Merged

fix(viewer): make the frontend build hermetic and actually verify dist/#66
rdasilveiracabral merged 1 commit into
mainfrom
harden-frontend-build-verification

Conversation

@sklinglernv

Copy link
Copy Markdown
Collaborator

Why

src/nooa/viewer/frontend-react/dist/ is 424 KB of minified JS that no reviewer can read. CI's frontend-build job exists to prove it is exactly what building the committed source produces — so the bundle stays a derived value rather than a trust input, and the only way into the viewer is a reviewable TypeScript diff.

Two independent defects meant it was not proving that. Both are exploitable by an external contributor.

1. The build was self-referential

dist/ is tracked, so Tailwind v4's automatic source detection scanned the previous bundle and treated words inside it as class-name candidates. Building over a populated dist/ emits three utilities a clean build does not:

build input CSS
dist/ populated (what CI did) 40.01 kB — matches committed ✅
dist/ empty (clean build) 39.95 kB — differs (.static, .table, .resize)

The committed state was a fixed point rather than a function of src/. CI passed only because it built on top of the artifact it was verifying, and a contributor could steer the emitted CSS by planting strings in dist/ instead of in reviewable source.

@source not '../dist' in src/index.css makes the build a pure function of src/. Verified identical output across a clean dist/, a populated dist/, and a dist/ seeded with decoy class names.

dist/ is regenerated here. The only CSS delta is removal of those three utilities — confirmed by a rule-by-rule diff. No source file uses them; resize-y and resize-none are separate utilities and are unaffected.

2. The staleness check missed added files

git diff --quiet only reports tracked, modified files. A PR that adds a file to dist/ passed cleanly — confirmed by committing a dist/assets/evil.js, which the old check waved through.

Now builds into an emptied dist/ and uses git status --porcelain, which reports modified, added and missing files alike. Also guards against a vacuous pass: git status --porcelain <path> prints nothing when the path does not resolve, which reads as success — the same failure mode the secret-scan job already guards against.

Verification

Ran the exact check locally against this commit:

case result
clean commit PASS
contributor adds dist/assets/evil.js FAIL ✅ (old check: passed)
contributor modifies the bundle FAIL ✅
dist/ missing (vacuous-pass guard) FAIL ✅

Output verified byte-identical on Node 20.19.5 and 22.20.0; the job now pins the version exactly, since its output is the security property. tests/viewer + tests/trace_explorer: 263 passed.

Follow-ups (not in this PR)

This check is only meaningful if it cannot be skipped. For pull_request GitHub runs the workflow as written in the PR, so a PR can delete this job — and main currently has no branch protection at all.

  • Make frontend-build a required status check on main — without this, a PR that removes the job merges green
  • CODEOWNERS on package*.jsonnpm run build executes vite.config.ts and every plugin in node_modules, which --ignore-scripts does not cover, so a lockfile change is arbitrary code execution in this job
  • CSP header on the viewer (default-src 'self') — the frontend is 100% same-origin, so this costs nothing and contains a compromised bundle regardless of build integrity
  • Default workflow permissions are currently write; drop to read

🤖 Generated with Claude Code

The committed viewer bundle is 424 KB of minified JS that no reviewer can
read. CI's job is to prove it is exactly what building the committed source
produces, so the bundle stays a derived value rather than a trust input. Two
independent defects meant it was not proving that.

1. The build was self-referential. dist/ is tracked, so Tailwind v4's
   automatic source detection scanned the PREVIOUS bundle and treated words
   inside it as class-name candidates. `npm run build` over a populated dist/
   emitted three utilities a clean build does not (.static, .table, .resize),
   making the committed state a fixed point rather than a function of src/.
   CI therefore passed only because it built on top of the very artifact it
   was verifying, and a contributor could steer the emitted CSS by planting
   strings in dist/ instead of in reviewable source.

   `@source not '../dist'` in src/index.css makes the build a pure function of
   src/. Verified identical output across a clean dist/, a populated dist/, and
   a dist/ seeded with decoy class names. dist/ is regenerated here; the only
   CSS delta is the removal of those three utilities, which no source file
   uses (resize-y and resize-none are separate utilities and are unaffected).

2. The staleness check used `git diff --quiet`, which only reports tracked,
   modified files. A PR that ADDS a file to dist/ passed it cleanly —
   confirmed by committing a dist/assets/evil.js, which the old check waved
   through. It now builds into an emptied dist/ and uses `git status
   --porcelain`, which reports modified, added and missing files alike.

Output was verified byte-identical on Node 20.19.5 and 22.20.0; the job now
pins the version exactly, since its output is the security property.

This check is only meaningful if it cannot be skipped. For `pull_request`
GitHub runs the workflow as written in the PR, so a PR can delete this job:
it must be configured as a required status check on main, which is tracked
separately along with CODEOWNERS on package*.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rdasilveiracabral
rdasilveiracabral merged commit e505bb9 into main Aug 1, 2026
5 checks passed
@rdasilveiracabral
rdasilveiracabral deleted the harden-frontend-build-verification branch August 1, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants