From 0271ad9469d2ed4f10abe74a2254aac19ad3cb34 Mon Sep 17 00:00:00 2001 From: Chiro Hiro Date: Wed, 3 Jun 2026 21:51:53 +0800 Subject: [PATCH] Keep the npm build token out of builder-stage layers Previously the credentials (.npmrc/.yarnrc.yml) were written in a dedicated builder RUN and never removed, so the npm token persisted in a builder-stage layer and the builder image filesystem (recoverable by anyone who can pull or run that stage / exported build cache). Now the credentials are created, used, and deleted inside the single build RUN (under --mount=type=secret,mode=0444), so they exist only for the duration of that one layer's execution and are gone from the resulting layer: - Dockerfile.template: the first builder RUN only prepares the home/workdir; it no longer writes credentials. - dockerfile.sh generate_build_command: emits a secret-mounted RUN that writes the .npmrc/.yarnrc.yml, runs the build, and removes them (set -e aborts the RUN on build failure, so no layer is committed). Verified by building the builder stage with a canary secret: the token is present in the builder image filesystem with the old pattern and absent with the new one. Enforced going forward by a new CI job (builder-secret-no-leak). SECURITY.md updated (no longer a known limitation); CHANGELOG updated; checksum.sha256 regenerated. --- .github/workflows/lint-and-test.yml | 29 ++++++++++++++++++++++++ CHANGELOG.md | 5 +++++ Dockerfile.template | 21 ++++------------- SECURITY.md | 34 ++++++++++++++-------------- checksum.sha256 | 4 ++-- dockerfile.sh | 35 +++++++++++++++++++++++------ 6 files changed, 85 insertions(+), 43 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 4d0a0ac..4b8f1d8 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -84,3 +84,32 @@ jobs: ! ./dockerfile.sh -t node -f .NPMRC --dry-run 2>/dev/null ! ./dockerfile.sh -t node -f "README.md;.npmrc" --dry-run 2>/dev/null echo "All invalid inputs correctly rejected" + + builder-secret-no-leak: + name: Build secret does not persist in builder layer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build builder stage and assert credentials are gone + run: | + set -euo pipefail + canary="LEAKCANARY_$(date +%s)_doNotUse" + printf '%s\n' "$canary" > token + work="$(mktemp -d)" + printf '{"name":"sample","version":"1.0.0"}\n' > "$work/package.json" + # Use THIS checkout's template (not the one on main): copy it in so + # dockerfile.sh doesn't fetch a remote template. + cp Dockerfile.template "$work/Dockerfile.template" + # `-b true` builds successfully without registry access; the build RUN + # still writes + uses + deletes the npm credentials. + ( cd "$work" && "$GITHUB_WORKSPACE/dockerfile.sh" -t node -b 'true' -f package.json ) + DOCKER_BUILDKIT=1 docker buildx build \ + --secret id=npm_access_token,src=token \ + --target builder --load -t devoff-builder-leak-check "$work" + # The token-bearing credential files must NOT exist in the builder image FS. + if docker run --rm devoff-builder-leak-check sh -c 'cat /home/ubuntu/.npmrc /home/ubuntu/.yarnrc.yml 2>/dev/null' | grep -qa "$canary"; then + echo "::error::npm token persisted in a builder-stage layer" + exit 1 + fi + echo "Build secret correctly absent from builder layer" diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc6af0..aa4753f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Security +- **Build-time npm token no longer persists in builder layers:** credentials are + created, used, and deleted inside a single `--mount=type=secret` build `RUN` + (`Dockerfile.template` + `dockerfile.sh`), so the token never lands in any image + layer. Enforced by a new CI job (`builder-secret-no-leak`) that builds the + builder stage with a canary secret and fails if it is found in the image. - **Closed the checksum coverage gap:** `checksum.sha256` now covers the executable scripts that are fetched via `curl | bash` (`check-gpg.sh`, `check-ssh.sh`, `dockerfile.sh`, `generate-yarn-npm.sh`) and diff --git a/Dockerfile.template b/Dockerfile.template index 5c3b3f3..097e11b 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -5,23 +5,10 @@ FROM {{builder_base_image}} AS builder ARG BUILDER_WORKDIR=/home/{{builder_user}}/app -# Mount NPM access token as secret. -# NOTE: the token is read from a BuildKit secret (kept out of the build context), -# but it is then written into .npmrc/.yarnrc.yml in this builder stage so the -# build step can authenticate. Those files live only in the throwaway builder -# stage (never COPYed into the runner), but they DO exist in builder-stage layers -# — do not export builder cache to a registry. See SECURITY.md ("Build-time -# credentials") for the planned single-RUN hardening. -RUN --mount=type=secret,id=npm_access_token NPM_ACCESS_TOKEN=$(cat /run/secrets/npm_access_token) && \ - echo "//registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN" > /home/{{builder_user}}/.npmrc && \ - echo "enableTelemetry: false\nnodeLinker: node-modules\nnpmScopes:" > /home/{{builder_user}}/.yarnrc.yml && \ - echo " orochi-network:" >> /home/{{builder_user}}/.yarnrc.yml && \ - echo " npmRegistryServer: \"https://registry.npmjs.org\"\n npmAlwaysAuth: true" >> /home/{{builder_user}}/.yarnrc.yml && \ - echo " npmAuthToken: $NPM_ACCESS_TOKEN" >> /home/{{builder_user}}/.yarnrc.yml && \ - echo " zkdb:" >> /home/{{builder_user}}/.yarnrc.yml && \ - echo " npmRegistryServer: \"https://registry.npmjs.org\"\n npmAlwaysAuth: true" >> /home/{{builder_user}}/.yarnrc.yml && \ - echo " npmAuthToken: $NPM_ACCESS_TOKEN" >> /home/{{builder_user}}/.yarnrc.yml && \ - mkdir -p ${BUILDER_WORKDIR} && \ +# Prepare the home + workdir. NPM credentials are deliberately NOT written here: +# they are created, used, and deleted inside the single build RUN below (under a +# BuildKit secret mount) so the token never persists in any image layer. +RUN mkdir -p ${BUILDER_WORKDIR} && \ chown -R {{builder_user}}:{{builder_group}} /home/{{builder_user}} # Set the working directory diff --git a/SECURITY.md b/SECURITY.md index 59ca82c..f023950 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,25 +50,25 @@ Treat changes to this repository with the same care as production secrets. These are repository settings, not code, and must be configured in the GitHub UI by an admin. They are what make the unsigned `checksum.sha256` trustworthy. -## Build-time credentials (known limitation) +## Build-time credentials `Dockerfile.template` reads the npm token from a BuildKit secret -(`--mount=type=secret`), which keeps it out of the build context. However, it -then writes `.npmrc`/`.yarnrc.yml` into the **builder stage** so the build can -authenticate. Those files: - -- are **never** copied into the runner image (multi-stage; only `runner` ships), and -- `dockerfile.sh` refuses `-f .npmrc|.yarnrc|.yarnrc.yml|.netrc` to prevent - accidental copying, **but** -- they **do** exist in builder-stage layers. - -**Mitigations today:** do not export builder cache to a registry -(`--cache-to mode=max` / `--target builder` would expose them). - -**Planned hardening (needs a live BuildKit build test before merge):** move -credential creation + use + deletion into a single `--mount=type=secret` `RUN` -as the non-root build user, so no standalone credential layer ever exists. -Tracked as a follow-up because it changes the critical build path. +(`--mount=type=secret`), which keeps it out of the build context. The credentials +(`.npmrc`/`.yarnrc.yml`) are **created, used, and deleted inside a single build +`RUN`**, so the token never persists in any image layer: + +- never copied into the runner image (multi-stage; only `runner` ships); +- `dockerfile.sh` refuses `-f .npmrc|.yarnrc|.yarnrc.yml|.netrc` (source and + destination, case-insensitive) to prevent accidental copying; and +- absent from **builder-stage** layers too, because the same RUN that writes them + also removes them (the layer diff contains no credential file). + +This is enforced in CI: the `builder-secret-no-leak` job builds the builder stage +with a canary secret and fails if the token is found in the builder image +filesystem. (`mode=0444` on the secret mount lets the non-root build user read +it; if the build fails, `set -e` aborts the RUN so no layer is committed.) + +Requires BuildKit (`DOCKER_BUILDKIT=1`, the default with `docker buildx`). ## Reporting a vulnerability diff --git a/checksum.sha256 b/checksum.sha256 index 47e2aee..69dcf14 100644 --- a/checksum.sha256 +++ b/checksum.sha256 @@ -1,9 +1,9 @@ 01bd313aa7a50bb9993a462e6c540e803771c56a083b6c7c0545e5f33617b5e5 ./check-ssh.sh -393b4564ea46f648e159deb02ee8287beaa90d4ae07bd9cf05c2e72379aae395 ./Dockerfile.template +2d85805201ba0706baa655e7ba5177c329ba7b00c540c92a5a3556fb703d5a44 ./dockerfile.sh 48f2d49fab1d7c52f6930bfb239874f1e61479210ceb260d0e2dd9a54c2877e6 ./gpg-list.asc 5b7f96cd62b02c59adb90159877bd61230c3d95eca3e19a1c4e5dfb4140a986c ./scripts/build-prod-next.sh +7ad81514968f2ded49102e8a88fee647b60299bd71b4f146fac6083fa7899192 ./Dockerfile.template 90ce4c5d804943369289ed6d3033cb711984d3c113426ccff891b290d30689a2 ./check-gpg.sh -a9c64582d8fc70112b17589c3dbcf31265b9ae5edeea10e491d0e6723e140a8b ./dockerfile.sh aad9d75f80076441f3164f81fb9fca4f4d55ec3c021129cad29633d89b24170d ./scripts/build-prod-nginx.sh b71e79b66d6a431fc0510d1f8146afbd847d6953b2d0d5314ffde2e868ea8cdc ./generate-yarn-npm.sh c442974a33647c4171806aab8affa3becd940e141fad39d58790c5bd88368209 ./ssh-allowed-signers diff --git a/dockerfile.sh b/dockerfile.sh index 0becb42..2f13d33 100755 --- a/dockerfile.sh +++ b/dockerfile.sh @@ -291,15 +291,14 @@ generate_runner_commands() { # Generate build command # ============================================================================ generate_build_command() { + # Determine the actual build step ("inner" command). + local inner if [[ -n "$BUILD_COMMAND" ]]; then echo "Using custom build command" >&2 - echo "# Run custom build command" - echo "RUN $BUILD_COMMAND" + inner="$BUILD_COMMAND" elif [[ -f "$CWD/scripts/build-prod.sh" ]]; then echo "Using local build-prod.sh" >&2 - echo "# Run build-prod.sh" - echo "RUN chmod +x scripts/build-prod.sh && \\" - echo " ./scripts/build-prod.sh" + inner="chmod +x scripts/build-prod.sh && ./scripts/build-prod.sh" else echo "Using remote build script" >&2 echo "WARNING: no local scripts/build-prod.sh found — the generated Dockerfile" >&2 @@ -307,9 +306,31 @@ generate_build_command() { echo " Pin BASE_REVISION to a commit SHA, or commit scripts/build-prod.sh," >&2 echo " to avoid trusting a moving branch. See SECURITY.md." >&2 local build_script="${BASE_URL}/scripts/build-prod-${DOCKER_TEMPLATE}.sh" - echo "# Run default build script (pinned via BASE_REVISION=${BASE_REVISION})" - echo "RUN curl -fsSL ${build_script} | bash -eo pipefail" + inner="curl -fsSL ${build_script} | bash -eo pipefail" fi + + # Emit a single RUN that mounts the npm token as a BuildKit secret, writes the + # .npmrc/.yarnrc.yml, runs the build, and removes the credential files — all in + # one layer, so the token never persists in any builder-stage layer. mode=0444 + # lets the non-root builder user read the mounted secret. If the build fails, + # `set -e` aborts the RUN (no layer is committed → still no leak). + # Emit the Dockerfile lines with printf '%s\n' so the literal \n / \" escape + # sequences pass through verbatim (the builder's /bin/sh dash echo interprets + # them at image-build time, matching the original template). + local h="/home/${BUILDER_USER}" + printf '%s\n' "# Build with npm auth mounted as a secret (token never persists in a layer)" + printf '%s\n' "RUN --mount=type=secret,id=npm_access_token,mode=0444 set -eu && \\" + printf '%s\n' " NPM_ACCESS_TOKEN=\$(cat /run/secrets/npm_access_token) && \\" + printf '%s\n' " echo \"//registry.npmjs.org/:_authToken=\$NPM_ACCESS_TOKEN\" > ${h}/.npmrc && \\" + printf '%s\n' " echo \"enableTelemetry: false\\nnodeLinker: node-modules\\nnpmScopes:\" > ${h}/.yarnrc.yml && \\" + printf '%s\n' " echo \" orochi-network:\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " echo \" npmRegistryServer: \\\"https://registry.npmjs.org\\\"\\n npmAlwaysAuth: true\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " echo \" npmAuthToken: \$NPM_ACCESS_TOKEN\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " echo \" zkdb:\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " echo \" npmRegistryServer: \\\"https://registry.npmjs.org\\\"\\n npmAlwaysAuth: true\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " echo \" npmAuthToken: \$NPM_ACCESS_TOKEN\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " { ${inner} ; } && \\" + printf '%s\n' " rm -f ${h}/.npmrc ${h}/.yarnrc.yml" } # ============================================================================