---
key: "container-image-hardening"
title: "Harden container images from Dockerfile to admission"
url: "https://softwaresecurity.com/domains/runtime/containers/container-image-hardening/"
api: "https://softwaresecurity.com/api/v1/articles/container-image-hardening.json"
domain: "runtime"
topic: "Runtime, cloud \u0026 APIs"
type: "Guide"
identifiers: ["CWE-250","CWE-538","CWE-494","CWE-1395","A02:2025","A03:2025","A08:2025","V13","V15"]
tags: ["Secrets management","SBOM","Signing and software updates","Kubernetes and containers"]
stages: ["build","release","operate"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Harden container images from Dockerfile to admission

Build minimal, non-root images pinned by digest, keep secrets out of every layer, then scan, sign and verify the exact digest before it runs with dropped privileges.

Canonical URL: https://softwaresecurity.com/domains/runtime/containers/container-image-hardening/

## In short

Build in one stage and ship a minimal runtime stage, such as distroless, pinned by digest. Run as a numeric non-root user, pass build credentials only through BuildKit secret mounts, and allowlist the build context. In CI, scan the pushed digest, attach an SBOM and sign it with cosign; at admission, verify the signer's identity, not just a signature. Run with a read-only root filesystem, all capabilities dropped, `no-new-privileges` and the default seccomp profile.

- Do pin base images as `name:tag@sha256:digest` and let a bot refresh the digest; rebuild often.
- Do pass build credentials with `RUN --mount=type=secret`; `ARG` and `ENV` values persist in image history and max-mode provenance.
- Do ship a runtime stage with no shell, package manager or compiler, running as a numeric non-root UID.
- Don't accept any valid signature: verify the exact CI workflow identity and issuer for the digest you deploy.
- Do run with `--read-only`, `--cap-drop=ALL`, `no-new-privileges` and the default seccomp profile, then prove the app still works.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Base image reference | `name:tag@sha256:<index digest>` | Keep the tag for readability; refresh the digest automatically (for example Renovate `pinDigests`). | https://docs.docker.com/build/building/best-practices/ |
| Runtime user | Numeric non-root UID:GID, for example `65532:65532` in distroless `:nonroot` images | The kubelet cannot verify `runAsNonRoot` against a user name. | https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/kuberuntime/security_context_others.go |
| Root filesystem | Read-only, with a small tmpfs for `/tmp` | `--read-only --tmpfs /tmp`, or `readOnlyRootFilesystem: true` with an `emptyDir`. | https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html |
| Linux capabilities | Drop `ALL` | Docker otherwise keeps 14 capabilities. The Restricted profile allows adding back only `NET_BIND_SERVICE`; listening above port 1024 avoids needing it. | https://kubernetes.io/docs/concepts/security/pod-security-standards/ |
| Privilege escalation | `--security-opt=no-new-privileges`, or `allowPrivilegeEscalation: false` | — | https://docs.docker.com/reference/cli/docker/container/run/ |
| Seccomp | Docker default profile; `seccompProfile.type: RuntimeDefault` set explicitly in Kubernetes | Never `seccomp=unconfined`. | https://docs.docker.com/engine/security/seccomp/ |
| Build credentials | `RUN --mount=type=secret,id=<id>,required=true` | Never `ARG` or `ENV`. | https://docs.docker.com/build/building/secrets/ |
| Dockerfile checks | `# check=error=true` | Dockerfile frontend 1.8+ and Buildx 0.15+. | https://docs.docker.com/build/checks/ |
| Provenance mode | Default `min`; use `max` only when no build argument carries a secret | `max` records build-argument values. | https://docs.docker.com/build/metadata/attestations/slsa-provenance/ |
| CI scan gate | Fail on high or critical findings that have a fix; record decisions for the rest | Team policy; scan the pushed digest. | — |
| Signature verification | Exact certificate identity and OIDC issuer, checked against the digest | — | https://docs.sigstore.dev/cosign/verifying/verify/ |
| Admission policy | `Deny`, `failurePolicy: Fail`, `required: true`, match `pods` and `pods/ephemeralcontainers` | Tested with Kyverno 1.19.1 on Kubernetes 1.37. | https://kyverno.io/docs/policy-types/image-validating-policy/ |

## Verify it

1. Run `docker build --check .` (exit 0) and confirm `docker history --no-trunc` for the image prints no secret-shaped build arguments; an export with `docker save` shows no `.env`, `.git` or `.npmrc` files in any layer.
2. Confirm `docker inspect --format '{{.Config.User}}'` returns a numeric non-root UID, and that `/proc/self/status` inside the running container shows `CapEff` and `CapBnd` of zero, `NoNewPrivs` 1 and `Seccomp` 2.
3. Confirm the CI scan of the pushed digest exits 0 under your severity policy, and that `docker buildx imagetools inspect --format '{{ json .SBOM.SPDX }}'` lists your application dependencies.
4. Run `cosign verify` on the digest with the expected `--certificate-identity` and `--certificate-oidc-issuer` and confirm it passes, then confirm the same command with another workflow identity fails.
5. In a test namespace, confirm that a pod, an init container and a `kubectl debug` ephemeral container using a wrongly signed image are rejected, while the signed image is admitted and rewritten to its digest.
6. Send a real request to the service running with `--read-only`, `--cap-drop=ALL` and `no-new-privileges` and confirm it still answers.

## What you will learn

- Find the secrets, tools and privileges an image carries
- Write a multi-stage, digest-pinned, non-root Dockerfile that uses BuildKit secret mounts
- Gate, sign and verify an image digest and enforce it at admission
- Run the image with minimal privileges and prove it still works

Audience: Developers and platform engineers who write Dockerfiles or operate container platforms

Before you start:

- Write and build a basic Dockerfile
- Read docker run flags or a Kubernetes pod spec

Time: About 36 minutes: 12 to read and 24 for the exercise, plus about 20 to rebuild and check the image yourself

## What an image carries into production

An image is an ordered set of filesystem layers plus a config that records the user, entrypoint, environment and build history. Anyone who can pull the image can read all of it. Deleting a file in a later layer hides it from the running container but leaves it in the earlier layer, and build arguments used by RUN steps are written into the history.

The base image decides most of what ships: a shell, a package manager, compilers, setuid binaries and hundreds of packages that scanners will report. Tags are mutable, so `FROM node:latest` can mean a different major version tomorrow. A process running as UID 0 is root on a kernel shared with the host and every other container, and Docker keeps 14 Linux capabilities for it unless you drop them.

Hardening has two halves. Ship less and know exactly what you shipped: a minimal base, a multi-stage build, no secrets, digest pins, scans, an SBOM and a signature. Then run it with the fewest privileges: a non-root user, a read-only root filesystem, no capabilities, seccomp and an admission check.

## Vulnerable: a Dockerfile that works and leaks

This builds and runs, and it resembles many real Dockerfiles. We built it on 2026-10-10 with a canary token as the build argument and a canary `.env` file in the directory. What we found:

- `docker history --no-trunc` printed the token twice, on the `ARG` line and on the `RUN` line, even though `.npmrc` was deleted within the same step.
- The `.env` file and the whole `.git` directory were in the `COPY` layer, readable from a `docker save` export.
- The process ran as root with Docker's default capabilities.
- `node:latest` had moved to Node.js 26.11.1, a Current release, while the team standard was Node.js 24 LTS.
- The image was 1.79 GB with gcc, make, curl and apt-get inside. Trivy 0.75.0 reported 3,157 OS package findings, 16 critical and 297 high, and the three fixable high findings were all in npm's own bundled packages, which the app never uses at run time.

```dockerfile
FROM node:latest
WORKDIR /app
COPY . .
ARG NPM_TOKEN
RUN echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" > .npmrc \
 && npm install \
 && rm .npmrc
EXPOSE 8080
CMD ["npm", "start"]

```

## Fixed: build in one stage, ship a minimal one

The build stage has npm and a shell. The runtime stage is a distroless Node.js 24 image with no shell or package manager, running as UID 65532. Both bases are pinned by index digest, so the bytes cannot change under the same tag, and `# check=error=true` turns Dockerfile check warnings such as `SecretsUsedInArgOrEnv` into build failures.

The registry token arrives through a BuildKit secret mount: it exists as a read-only `0400` file only while that `RUN` step executes, and `required=true` fails the build if the secret is missing. `npm ci` installs exactly what the lockfile pins, `--omit=dev` leaves test tooling out, and `--ignore-scripts` stops dependency install scripts; drop that flag only for reviewed packages that need a build step. The application files stay root-owned, so the runtime user can read them but not change them.

In our run, the history and every layer were free of both canary values, Trivy counted 14 Debian packages and found nothing fixable at high or critical severity, and `docker image ls` reported 221 MB instead of 1.79 GB. To build it, pass the token file as a secret and use the current directory as the context: `docker build --secret id=npmrc,src=$HOME/.npmrc -t status-api:1.0.0 .`

```dockerfile
# syntax=docker/dockerfile:1
# check=error=true

# Build stage: has npm and a shell; never shipped.
FROM node:24-trixie-slim@sha256:173f125896c3b47ddf056734c7ea789d04595a6a08769a8f78e0df642781fb66 AS build
WORKDIR /app
COPY package.json package-lock.json ./
# The registry token exists only while this RUN executes.
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
    npm ci --omit=dev --ignore-scripts
COPY server.js ./

# Runtime stage: no shell or package manager, numeric non-root user.
FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:9eeb7f5887d0e239e78264b06f7f11d2e14be534050481803a9e4728fcdd278e
WORKDIR /app
COPY --from=build /app /app
USER 65532:65532
EXPOSE 8080
CMD ["server.js"]

```

## Allowlist the build context

`COPY . .` sends whatever is in the directory: `.env` files, Git history, local `node_modules` and test fixtures that may hold real data. An allowlist `.dockerignore` is safer than a list of exclusions, because a new secret file stays out by default. The last matching line wins, so the exceptions come after the catch-all.

Still copy named files in the Dockerfile rather than the whole context. ASVS 5.0 requirement 13.4.1 asks that deployments leave out source-control metadata such as `.git`, or make it inaccessible.

```text
# Allowlist: send only what the image needs to the builder.
*
!package.json
!package-lock.json
!server.js

```

## Keep secrets out of layers, history and provenance

`ARG` and `ENV` values persist. Docker's `SecretsUsedInArgOrEnv` check flags them, and a provenance attestation in max mode records build-argument values. Secret mounts never enter the image or its provenance; in our max-mode provenance the secret appeared only as its ID, `npmrc`, with no value. Use an SSH mount (`--ssh`) for Git access inside `RUN` steps; the predefined `GIT_AUTH_TOKEN` secret covers private Git build contexts and `ADD` of Git URLs. Give the running container its secrets from the orchestrator at run time, never from the image.

If a token was ever built into an image that left the build machine, revoke it. Rebuilding removes it from new images, not from copies already pulled or cached in registries.

```bash
# Exits 1 on SecretsUsedInArgOrEnv and other Dockerfile check violations.
docker build --check .

# Should print nothing: no secret-shaped build arguments in the image history.
docker history --no-trunc --format '{{.CreatedBy}}' status-api:1.0.0 \
  | grep -E '(TOKEN|SECRET|PASSWORD|KEY)[A-Z_]*='

```

## Pin base images by digest and keep them moving

A digest names exact bytes; a tag is a pointer the publisher can move. Pin the multi-platform index digest and keep the tag beside it for readability, as in `node:24-trixie-slim@sha256:173f…`. Before trusting a new base, verify its signature: distroless images are signed keylessly, and the command below checks the distroless release identity.

Pinning also freezes vulnerabilities, so automate the refresh. Renovate's `pinDigests` opens a pull request when the image behind a pinned tag changes; rebuild and redeploy on that signal and on a schedule. Pin scanners and CI actions the same way. On 19 March 2026, attackers using compromised credentials published a malicious Trivy 0.69.4 binary and image and force-pushed 76 of 77 `trivy-action` tags to credential-stealing code (CVE-2026-33634); on 22 March malicious 0.69.5 and 0.69.6 images followed on Docker Hub. Workflows that referenced a full commit SHA kept running the code they had pinned.

```bash
# Resolve the multi-platform index digests once and write them into the FROM lines.
docker buildx imagetools inspect node:24-trixie-slim --format '{{println .Manifest.Digest}}'
docker buildx imagetools inspect gcr.io/distroless/nodejs24-debian13:nonroot --format '{{println .Manifest.Digest}}'

# Check that the runtime base was signed by the distroless release identity.
cosign verify gcr.io/distroless/nodejs24-debian13:nonroot@sha256:9eeb7f5887d0e239e78264b06f7f11d2e14be534050481803a9e4728fcdd278e \
  --certificate-oidc-issuer https://accounts.google.com \
  --certificate-identity keyless@distroless.iam.gserviceaccount.com

```

## Scan the digest in CI, then keep scanning

Scan the digest you pushed, not a local tag, and fail the job on findings you can act on: high or critical issues with a fixed version available. Record decisions about the rest, ideally as VEX statements, so a growing list does not train people to ignore the scanner. New CVEs arrive after the build, so keep rescanning stored digests in the registry and alert on new fixable findings.

Scanners read package databases. Distroless images keep Debian's package metadata, which is how Trivy listed the runtime's packages, but a static binary or a tool fetched with curl has no package record and needs an SBOM entry instead. The comparison below comes from our test images on 2026-10-10.

Vulnerability scans tell you which packages need updates, not whether the image is configured to a benchmark. For CIS and DISA STIG checks of the image itself, with a deviation register enforced in CI, see /domains/runtime/containers/image-hardening-benchmarks/.

```bash
IMAGE="ghcr.io/acme/status-api@${DIGEST}"   # the digest this build just pushed
TRIVY=aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa

# Fails the job when a high or critical finding has a fixed version available.
docker run --rm -v trivy-cache:/root/.cache/ "$TRIVY" image \
  --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE"

```

| Measure | node:latest, single stage | Distroless, multi-stage |
| --- | --- | --- |
| Size reported by `docker image ls` | 1.79 GB | 221 MB |
| OS package findings (Trivy 0.75.0) | 3,157 (16 critical, 297 high) | 31 (23 medium, 8 low) |
| Fixable high or critical findings | 3, all in npm's bundled packages | 0 |
| Shell, package manager, compiler | Present | Absent |
| Runtime user | root (UID 0) | UID 65532 |
| Canary secrets in history or layers | Token and .env found | None |

## Attach an SBOM to the image you ship

Generate the inventory from the built image, not the source tree. BuildKit can attach an SPDX SBOM and provenance to the image index at build time; our build produced an SPDX 2.3 document listing libc6, OpenSSL, Node.js 24.21.0, ms 2.1.3 and the app. By default only the final stage is scanned, which is the stage you ship; set `BUILDKIT_SBOM_SCAN_STAGE` when build stages matter. With Docker's default `docker` driver this needs the containerd image store.

If consumers want CycloneDX, generate it with Syft or Trivy from the same digest and attach it with `cosign attest`, as shown in the next section; the SBOM generation guide at /domains/supply-chain/sbom/generate-release-inventory/ covers what a release inventory should contain.

```bash
# SPDX SBOM and provenance attached to the image index (docker driver needs the containerd image store).
docker buildx build --secret id=npmrc,src="$HOME/.npmrc" \
  --sbom=true --provenance=mode=max \
  -t ghcr.io/acme/status-api:1.0.0 --push .

docker buildx imagetools inspect ghcr.io/acme/status-api:1.0.0 \
  --format '{{ json .SBOM.SPDX }}' | jq -r '.packages[] | "\(.name) \(.versionInfo)"'

```

## Sign the digest in CI and verify the signer

Keyless signing uses a short-lived certificate tied to the CI workflow's OIDC identity and records the signature in Sigstore's transparency log, so there is no long-lived signing key to steal. In GitHub Actions the signing job needs `permissions: id-token: write`. Sign and attest the digest you pushed, never a tag.

Verification must check who signed, not only that a valid signature exists. Pin the certificate identity to the release workflow and ref, and the issuer to your CI's OIDC provider. When we checked Trivy 0.75.0 against a guessed workflow path, cosign refused and named the identity that actually signed it, `.github/workflows/reusable-release.yaml@refs/tags/v0.75.0`. An identity regular expression is convenient but accepts any workflow in the repository. The artifact signature policy guide at /domains/supply-chain/provenance/artifact-signature-policy/ and the signed provenance lab at /labs/artifact-policy/ go further.

```bash
# Release job (GitHub Actions with permissions: id-token: write): sign and attest the pushed digest.
cosign sign --yes "ghcr.io/acme/status-api@${DIGEST}"
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "ghcr.io/acme/status-api@${DIGEST}"

# Before deploying: require the exact release workflow and issuer, by digest.
cosign verify "ghcr.io/acme/status-api@${DIGEST}" \
  --certificate-identity "https://github.com/acme/status-api/.github/workflows/release.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"

```

## Enforce the signature at admission

A CI check protects only the pipeline that runs it; admission control checks every pod the cluster accepts. This Kyverno ImageValidatingPolicy, the stable `policies.kyverno.io/v1` type in Kyverno 1.19, denies pods whose `ghcr.io/acme` images were not signed by the release workflow and rewrites tags to digests, so the verified bytes are the ones that run.

We tested this structure on Kubernetes 1.37 with Kyverno 1.19.1. A correctly signed image was admitted and pinned to its digest, and a wrongly signed image was rejected. Two variations exposed gaps. With `required` set to `false`, an init container using a wrongly signed image was admitted, because the validation expression covers only regular containers. Without `pods/ephemeralcontainers` in the match rules, `kubectl debug` attached a wrongly signed container. Kyverno also reported the policy not ready until we granted its controllers read access to `pods/ephemeralcontainers` through an aggregated ClusterRole.

Images outside the glob are not checked at all, so pair the policy with a registry allowlist. `failurePolicy: Fail` turns a registry outage or rate limit into blocked deployments; anonymous Docker Hub rate limits did exactly that during our tests. Mirror images into your own registry and document a break-glass procedure.

```yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: require-signed-release-images
spec:
  validationActions: [Deny]
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods", "pods/ephemeralcontainers"]
        operations: ["CREATE", "UPDATE"]
  matchImageReferences:
    - glob: "ghcr.io/acme/*"
  attestors:
    - name: release
      cosign:
        keyless:
          identities:
            - subject: "https://github.com/acme/status-api/.github/workflows/release.yml@refs/heads/main"
              issuer: "https://token.actions.githubusercontent.com"
  validationConfigurations:
    mutateDigest: true
    verifyDigest: true
    required: true
  validations:
    - expression: >-
        images.containers.map(image, verifyImageSignatures(image, [attestors.release])).all(e, e > 0)
      message: "image is not signed by the acme release workflow"

```

## Run with the fewest privileges the image allows

Docker keeps 14 capabilities and allows privilege escalation through setuid binaries unless told otherwise. Drop every capability, block new privileges, mount the root filesystem read-only with a small tmpfs for `/tmp`, and set the user explicitly. Docker applies its default seccomp profile, which blocks about 44 system calls, unless you override it; never use `seccomp=unconfined`.

In Kubernetes the same intent is `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`, `readOnlyRootFilesystem: true`, `runAsNonRoot: true` and `seccompProfile.type: RuntimeDefault`; the Restricted Pod Security Standard requires all of these except the read-only root filesystem. Set `runAsUser` to a number. The kubelet refuses `runAsNonRoot` when the image user is a name it cannot verify, and CVE-2026-46680 let crafted images evade `runAsNonRoot` on containerd releases before 1.7.32, 2.0.9, 2.2.4 and 2.3.1. Never mount the Docker socket into a container; access to it amounts to root on the host.

```bash
docker run -d --name status-api -p 8080:8080 \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop=ALL --security-opt=no-new-privileges \
  --user 65532:65532 --pids-limit=256 --memory=256m \
  status-api:1.0.0

```

## Prove the settings took effect

Read the effective state from inside the container instead of trusting the command line. `CapEff` should be all zeros, `NoNewPrivs` 1 and `Seccomp` 2, which means filter mode. With default `docker run` flags our non-root image still showed `CapBnd` `00000000a80425fb`, the default capability set that a setuid or file-capability binary could still use, and `NoNewPrivs` 0. With the hardened flags the bounding set dropped to zero, and the service still answered HTTP 200 with a read-only root.

```bash
docker inspect --format 'User={{.Config.User}} ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{.HostConfig.CapDrop}} SecurityOpt={{.HostConfig.SecurityOpt}}' status-api

# distroless has no shell, so ask Node.js to print its own kernel security state.
docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges \
  --user 65532:65532 status-api:1.0.0 \
  -e 'console.log(require("fs").readFileSync("/proc/self/status","utf8").match(/^(CapEff|CapBnd|NoNewPrivs|Seccomp):.*$/gm).join("\n"))'
# Expected: CapEff and CapBnd 0000000000000000, NoNewPrivs 1, Seccomp 2

```

## Pitfalls

Most hardening regressions come from a quick fix for a build or startup error.

- Deleting a file in a later RUN step does not remove it from the image.
- A `USER` given as a name breaks `runAsNonRoot` verification in Kubernetes; use a number.
- `chown -R` of the app to the runtime user lets a compromised process rewrite its own code; keep files root-owned unless the app must write there.
- A read-only root filesystem without a tmpfs or volume for `/tmp` and caches crashes apps at startup; find the write path instead of reverting to read-write.
- Distroless has no shell. Use the `:debug` variants outside production, or `kubectl debug` with an ephemeral container your admission policy also covers.
- Digest pins without automated refresh turn into a growing list of known vulnerabilities.
- Sign, verify and deploy the same digest: a signature on the multi-platform index digest does not match a platform-specific digest, and the reverse.
- Scanners miss what has no package metadata: static binaries, curl-installed tools and vendored code.

## Tooling

Use one tool per job and pin each one by digest or commit SHA, like any other build input.

| Job | Options |
| --- | --- |
| Minimal bases | Distroless, Chainguard Images, Docker Hardened Images, Red Hat UBI Micro, scratch for static binaries |
| Dockerfile checks | `docker build --check`, Hadolint |
| Image scanning | Trivy, Grype, Docker Scout, registry scanning |
| SBOM | BuildKit `--sbom`, Syft, Trivy |
| Signing and verification | Sigstore cosign, Notation |
| Admission | Kyverno, Sigstore policy-controller, OPA Gatekeeper with Ratify |
| Digest refresh | Renovate `pinDigests` |

## Guided practice

A Node.js service ships from the Dockerfile below. Use the supplied observations to decide what to change and which evidence proves the change worked.

### Supplied material

```text
Dockerfile (current)
  FROM node:latest
  WORKDIR /app
  COPY . .
  ARG NPM_TOKEN
  RUN echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" > .npmrc && npm install && rm .npmrc
  CMD ["npm", "start"]
Observed on 2026-10-10
  docker history --no-trunc: 'ARG NPM_TOKEN=npm_live_...' and 'RUN |1 NPM_TOKEN=npm_live_...'
  docker run --rm --entrypoint id IMAGE: uid=0(root)
  /app contains .env and .git
  node --version: v26.11.1 (team standard: Node.js 24 LTS)
  Trivy: 16 critical and 297 high OS findings; 3 fixable high findings, all under /usr/local/lib/node_modules/npm
  docker image ls: 1.79 GB
Teammate's proposal: add 'RUN rm -f .env' at the end and 'USER node'.
```

### 1. Explain the token leak

Explain why the token is exposed although `.npmrc` is deleted in the same `RUN` step, and what to do first.

Worked answer: The `RUN` step used the build argument, so its value is stored in the image history; `.npmrc` never reached a layer, but the `ARG` did. Revoke the token first, then move it to a BuildKit secret mount with `required=true`.

### 2. Evaluate the teammate's proposal

Decide whether `RUN rm -f .env` plus `USER node` fixes the image.

Worked answer: No. The later `rm` leaves `.env` in the `COPY` layer, readable from `docker save`, and `USER node` leaves the token, the mutable tag, the compilers and npm in place. Use an allowlist `.dockerignore` and a multi-stage build that copies only named files.

### 3. Explain the version drift

Explain why the image runs Node.js 26 and how to prevent it.

Worked answer: The `latest` tag moved to the newest Current release. Pin `node:24-<variant>@sha256:<index digest>`, refresh the digest with a bot such as Renovate, and rebuild on that signal and on a schedule.

### 4. Choose the runtime stage

Pick the runtime base, user and file ownership.

Worked answer: A distroless Node.js 24 `:nonroot` base pinned by digest, `USER 65532:65532` as numbers, and application files left root-owned so the process cannot modify them; no shell, npm or compiler in the final stage.

### 5. Define the acceptance evidence

List the checks that must pass for the new digest.

Worked answer: `docker build --check` exits 0; the history grep prints nothing; a layer scan finds no `.env`, `.git` or `.npmrc`; the user is numeric non-root; `/proc/self/status` shows `CapEff` and `CapBnd` zero, `NoNewPrivs` 1 and `Seccomp` 2; the scan gate exits 0; `cosign verify` passes with the release identity; admission rejects a wrongly signed image; and the service answers requests with a read-only root.

Deliverable: A revised Dockerfile, `.dockerignore` and run command, plus an evidence record listing each check above with its output for the new image digest.

Scope: The observations come from a local test build, so the reasoning can be completed on paper. Running the checks needs Docker with BuildKit; the signing and admission steps also need a registry and a test cluster.

## Executable practice

- [Repair a security pipeline for GitHub Actions and GitLab CI: project ZIP](https://softwaresecurity.com/downloads/projects/security-pipeline-365c6d54e88d6c1c.zip) — Node.js 22+ · no dependencies · GitHub Actions and GitLab CI YAML
- [Gate an image build on a CIS scan and a deviation register: project ZIP](https://softwaresecurity.com/downloads/projects/image-benchmark-gate-b76ad8a05569f566.zip) — OpenSCAP 1.4.4 and SSG 0.1.82 in Docker

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [Docker: build secrets](https://docs.docker.com/build/building/secrets/)
- [Docker: building best practices](https://docs.docker.com/build/building/best-practices/)
- [Docker: provenance attestations (min and max modes)](https://docs.docker.com/build/metadata/attestations/slsa-provenance/)
- [Docker: seccomp security profiles](https://docs.docker.com/engine/security/seccomp/)
- [OWASP Docker Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html)
- [Kubernetes Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/). Kubernetes v1.37.
- [Sigstore: verifying signatures with cosign](https://docs.sigstore.dev/cosign/verifying/verify/)
- [Kyverno: ImageValidatingPolicy](https://kyverno.io/docs/policy-types/image-validating-policy/)
- [NIST SP 800-190: Application Container Security Guide](https://csrc.nist.gov/pubs/sp/800/190/final). September 2017.
- [GHSA-69fq-xp46-6x23: Trivy supply chain compromise (CVE-2026-33634)](https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23). 21 March 2026.
- [containerd GHSA-fqw6-gf59-qr4w: runAsNonRoot evasion (CVE-2026-46680)](https://github.com/containerd/containerd/security/advisories/GHSA-fqw6-gf59-qr4w). 20 May 2026.