build(security): apply install-script allowlist to Dockerfile npm ci (#69) #90

Merged
james merged 1 commit from 69-dockerfile-allow-scripts into main 2026-06-18 01:30:11 +00:00
Owner

Closes #69. Resolves the Dockerfile is exempt caveat carried by ADR-0014 since #16.

What lands

  • Dockerfiledeps stage now runs npm ci --ignore-scripts && CI=true npx allow-scripts, mirroring the PR-workflow pattern from #46 / ADR-0010 verbatim. Both paths share one allowlist (package.json lavamoat.allowScripts).
  • docs/ci.mdInstall scripts (allowlisted) section extended to cover both PR-time and release-build gates, including the CI=true detail for lefthook's short-circuit.
  • ADR-0010Status updated, Scope note added in the constraints, and a new Scope amendment (2026-06-17, #69) section at the end explaining the gap, the fix, and the consequence.
  • ADR-0014Negative caveat about the Dockerfile being exempt is struck through and replaced with a pointer to this PR.

Why CI=true is scoped to the allow-scripts invocation only

lefthook's postinstall calls lefthook install -f, which short-circuits when CI=true. Inside the Docker build there is no .git and no CI=true from the runner, so without that env scoping the lefthook script would try (and fail) to wire up git hooks. Setting CI=true only on the single command line keeps the variable out of the builder and runtime stages, which set their own env.

Verification (podman)

Positive path — full multi-stage build succeeds end-to-end. The deps stage log shows:

running lifecycle scripts for event "preinstall"
running lifecycle scripts for event "install"
  - next>sharp
running lifecycle scripts for event "postinstall"
  - eslint-config-next>eslint-import-resolver-typescript>unrs-resolver
  - lefthook
  - vitest>vite>esbuild
running lifecycle scripts for top level package

All four allowlisted scripts ran; no errors or warnings. Lefthook's postinstall short-circuited silently.

Destructive proof the gate fires inside the container — temporarily dropped vitest>vite>esbuild from lavamoat.allowScripts, rebuilt the deps stage, observed:

@lavamoat/allow-scripts has detected dependencies without configuration. explicit configuration required.
run "allow-scripts auto" to automatically populate the configuration.

packages missing configuration:
- vitest>vite>esbuild [1 location(s)]
Error: building at STEP "RUN npm ci --ignore-scripts  && CI=true npx allow-scripts": while running runtime: exit status 1

Build aborted at the exact step we hardened. Allowlist restored.

Acceptance criteria

  • docker build . (run as podman build) produces an image whose runtime stage is unchanged in shape — only the deps install layer's contents differ.
  • Purpose-built negative test demonstrates the gate works inside the container (see destructive proof above).
  • Dockerfile install policy is documented in docs/ci.md Install scripts (allowlisted).
  • ADR-0010 amended (scope widened) and ADR-0014 amended (caveat struck) — both kept accurate.

Composes with

  • #16 / ADR-0014 — the release pipeline. This PR closes the last unaddressed "Negative" item in that ADR's Consequences.
  • #46 / ADR-0010 — the workflow-side allowlist. This PR widens the same policy to the release path.
  • #44 / ADR-0009 — Renovate quarantine. Even if a malicious release lands inside the 7-day window, it can no longer execute during the image build either.

Test plan

  • CI green on this PR's existing pr.yml jobs (the workflow paths are unchanged; only the Dockerfile path moved).
  • After merge, the next image_scan run rebuilds the image with the new deps stage and reports clean.
  • Whenever a Renovate npm-deps PR pulls in a new install-script transitive, confirm the Dockerfile build now fails the same way the PR workflow does, not just the workflow.
Closes #69. Resolves the *Dockerfile is exempt* caveat carried by ADR-0014 since #16. ## What lands - **`Dockerfile`** — `deps` stage now runs `npm ci --ignore-scripts && CI=true npx allow-scripts`, mirroring the PR-workflow pattern from #46 / ADR-0010 verbatim. Both paths share one allowlist (`package.json` `lavamoat.allowScripts`). - **`docs/ci.md`** — *Install scripts (allowlisted)* section extended to cover both PR-time and release-build gates, including the `CI=true` detail for lefthook's short-circuit. - **ADR-0010** — *Status* updated, *Scope* note added in the constraints, and a new *Scope amendment (2026-06-17, #69)* section at the end explaining the gap, the fix, and the consequence. - **ADR-0014** — *Negative* caveat about the Dockerfile being exempt is struck through and replaced with a pointer to this PR. ## Why `CI=true` is scoped to the `allow-scripts` invocation only `lefthook`'s postinstall calls `lefthook install -f`, which short-circuits when `CI=true`. Inside the Docker build there is no `.git` and no `CI=true` from the runner, so without that env scoping the lefthook script would try (and fail) to wire up git hooks. Setting `CI=true` only on the single command line keeps the variable out of the `builder` and `runtime` stages, which set their own env. ## Verification (podman) **Positive path** — full multi-stage build succeeds end-to-end. The `deps` stage log shows: ``` running lifecycle scripts for event "preinstall" running lifecycle scripts for event "install" - next>sharp running lifecycle scripts for event "postinstall" - eslint-config-next>eslint-import-resolver-typescript>unrs-resolver - lefthook - vitest>vite>esbuild running lifecycle scripts for top level package ``` All four allowlisted scripts ran; no errors or warnings. Lefthook's postinstall short-circuited silently. **Destructive proof the gate fires inside the container** — temporarily dropped `vitest>vite>esbuild` from `lavamoat.allowScripts`, rebuilt the `deps` stage, observed: ``` @lavamoat/allow-scripts has detected dependencies without configuration. explicit configuration required. run "allow-scripts auto" to automatically populate the configuration. packages missing configuration: - vitest>vite>esbuild [1 location(s)] Error: building at STEP "RUN npm ci --ignore-scripts && CI=true npx allow-scripts": while running runtime: exit status 1 ``` Build aborted at the exact step we hardened. Allowlist restored. ## Acceptance criteria - [x] `docker build .` (run as `podman build`) produces an image whose runtime stage is unchanged in shape — only the `deps` install layer's contents differ. - [x] Purpose-built negative test demonstrates the gate works inside the container (see destructive proof above). - [x] Dockerfile install policy is documented in `docs/ci.md` *Install scripts (allowlisted)*. - [x] ADR-0010 amended (scope widened) and ADR-0014 amended (caveat struck) — both kept accurate. ## Composes with - #16 / ADR-0014 — the release pipeline. This PR closes the last unaddressed "Negative" item in that ADR's Consequences. - #46 / ADR-0010 — the workflow-side allowlist. This PR widens the same policy to the release path. - #44 / ADR-0009 — Renovate quarantine. Even if a malicious release lands inside the 7-day window, it can no longer execute during the image build either. ## Test plan - [ ] CI green on this PR's existing `pr.yml` jobs (the workflow paths are unchanged; only the Dockerfile path moved). - [ ] After merge, the next `image_scan` run rebuilds the image with the new `deps` stage and reports clean. - [ ] Whenever a Renovate npm-deps PR pulls in a new install-script transitive, confirm the **Dockerfile** build now fails the same way the PR workflow does, not just the workflow.
build(security): apply install-script allowlist to Dockerfile npm ci (#69)
All checks were successful
Secrets / gitleaks (pull_request) Successful in 18s
PR / OSV-Scanner (pull_request) Successful in 23s
PR / Static analysis (Semgrep) (pull_request) Successful in 44s
PR / Lint (pull_request) Successful in 1m53s
PR / Typecheck (pull_request) Successful in 3m26s
PR / Build (pull_request) Successful in 4m7s
PR / Test (postgres) (pull_request) Successful in 4m9s
PR / Test (sqlite) (pull_request) Successful in 4m21s
PR / npm audit (pull_request) Successful in 4m37s
PR / Trivy (image) (pull_request) Successful in 5m14s
15e3adf1a4
The PR-time install-script policy from ADR-0010 only covered
`.forgejo/workflows/`; the Dockerfile's `RUN npm ci` ran lifecycle
scripts unrestricted, which meant a compromised transitive that
survived the upstream gates (Renovate quarantine, OSV / Trivy /
npm audit) could still execute `postinstall` during the release
image build and bake the result into the published image. Caveat
was already documented in ADR-0014.

The `deps` stage now mirrors the workflow pattern verbatim:

  RUN npm ci --ignore-scripts && CI=true npx allow-scripts

Both paths share the single allowlist in package.json
`lavamoat.allowScripts`. `CI=true` is scoped to the single
allow-scripts call so lefthook's postinstall short-circuits inside
the build (no `.git` to install hooks into); it does not leak into
the builder or runtime stages.

Verified with podman:
- full build succeeds; deps-stage log shows allow-scripts running
  sharp, esbuild, unrs-resolver, and lefthook with no errors
- destructive test: dropping `vitest>vite>esbuild` from the
  allowlist and rebuilding fails at the same step with lavamoat's
  "packages missing configuration" message — gate fires inside
  the container

ADR-0010 gains a "Scope amendment" section; ADR-0014 strikes the
"Dockerfile is exempt" caveat and points back at this PR.
james merged commit 1dc3db32be into main 2026-06-18 01:30:11 +00:00
Sign in to join this conversation.
No description provided.