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
29 changes: 29 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 4 additions & 17 deletions Dockerfile.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 17 additions & 17 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions checksum.sha256
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 28 additions & 7 deletions dockerfile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -291,25 +291,46 @@ 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
echo " will fetch and execute the build script from dev-off at build time." >&2
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"
}

# ============================================================================
Expand Down
Loading