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:digestand let a bot refresh the digest; rebuild often. - Do pass build credentials with
RUN --mount=type=secret;ARGandENVvalues 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-privilegesand the default seccomp profile, then prove the app still works.
| 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/ |
- Run
docker build --check .(exit 0) and confirmdocker history --no-truncfor the image prints no secret-shaped build arguments; an export withdocker saveshows no.env,.gitor.npmrcfiles in any layer. - Confirm
docker inspect --format '{{.Config.User}}'returns a numeric non-root UID, and that/proc/self/statusinside the running container showsCapEffandCapBndof zero,NoNewPrivs1 andSeccomp2. - 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. - Run
cosign verifyon the digest with the expected--certificate-identityand--certificate-oidc-issuerand confirm it passes, then confirm the same command with another workflow identity fails. - In a test namespace, confirm that a pod, an init container and a
kubectl debugephemeral container using a wrongly signed image are rejected, while the signed image is admitted and rewritten to its digest. - Send a real request to the service running with
--read-only,--cap-drop=ALLandno-new-privilegesand confirm it still answers.
- Applies to
- Docker Engine with BuildKit Dockerfile frontend 1.8+ and Buildx 0.15+ for build checks (tested Docker 29.4.3, BuildKit 0.29.0)
- cosign 3.x (tested 3.1.3)
- Trivy Tested 0.75.0; never 0.69.4, or the 0.69.5 and 0.69.6 images (CVE-2026-33634)
- Kyverno 1.19+ for ImageValidatingPolicy policies.kyverno.io/v1 (tested 1.19.1)
- Kubernetes Pod Security Standards Restricted profile (tested 1.37)
- containerd 1.7.32, 2.0.9, 2.2.4, 2.3.1 or later for CVE-2026-46680
Container images built with Docker or BuildKit and run on Kubernetes. A clean scan does not prove that the running digest is the one that was signed.
- Part of
- Workflow: Fix a finding
Learning goals and prerequisites
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
Before you start
Developers and platform engineers who write Dockerfiles or operate container platforms
- Write and build a basic Dockerfile
- Read docker run flags or a Kubernetes pod spec
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.
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-truncprinted the token twice, on theARGline and on theRUNline, even though.npmrcwas deleted within the same step.- The
.envfile and the whole.gitdirectory were in theCOPYlayer, readable from adocker saveexport. - The process ran as root with Docker's default capabilities.
node:latesthad 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.
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"]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 .
# 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"]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.
# Allowlist: send only what the image needs to the builder.
*
!package.json
!package-lock.json
!server.jsARG 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.
# 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_]*='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.
# 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.comScan 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/.
| 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 |
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"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.
# 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)"'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.
# 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"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.
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"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.
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.0Read 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.
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 2Most 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
USERgiven as a name breaksrunAsNonRootverification in Kubernetes; use a number. chown -Rof 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
/tmpand caches crashes apps at startup; find the write path instead of reverting to read-write. - Distroless has no shell. Use the
:debugvariants outside production, orkubectl debugwith 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.
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 scenario material
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
.npmrcis deleted in the sameRUNstep, and what to do first.Expected result and reasoning
The
RUNstep used the build argument, so its value is stored in the image history;.npmrcnever reached a layer, but theARGdid. Revoke the token first, then move it to a BuildKit secret mount withrequired=true.2. Evaluate the teammate's proposal
Decide whether
RUN rm -f .envplusUSER nodefixes the image.Expected result and reasoning
No. The later
rmleaves.envin theCOPYlayer, readable fromdocker save, andUSER nodeleaves the token, the mutable tag, the compilers and npm in place. Use an allowlist.dockerignoreand 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.
Expected result and reasoning
The
latesttag moved to the newest Current release. Pinnode: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.
Expected result and reasoning
A distroless Node.js 24
:nonrootbase pinned by digest,USER 65532:65532as 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.
Expected result and reasoning
docker build --checkexits 0; the history grep prints nothing; a layer scan finds no.env,.gitor.npmrc; the user is numeric non-root;/proc/self/statusshowsCapEffandCapBndzero,NoNewPrivs1 andSeccomp2; the scan gate exits 0;cosign verifypasses with the release identity; admission rejects a wrongly signed image; and the service answers requests with a read-only root.
.dockerignore and run command, plus an evidence record listing each check above with its output for the new image digest.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.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Repair a security pipeline for GitHub Actions and GitLab CI
Repair two pipelines that run gitleaks, Semgrep, OSV-Scanner, Trivy and cosign but cannot fail and trust pull requests, until every action is pinned to a commit SHA, every image to a digest, gates block and only the main branch signs the pushed digest.
In the extracted folder, run:
node --test exercise.test.mjs13 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Static checks of the YAML text; the pipelines are not run. Each scan command was run locally with the pinned images, and the reference workflow passed actionlint 1.7.12 and zizmor 1.30.1.
Gate an image build on a CIS scan and a deviation register
Repair a CI gate that reads a real OpenSCAP scan of ubuntu:24.04 against the CIS Level 1 Server profile, until unregistered failures, errors, expired deviations and scans of the wrong profile or content version fail the build.
Needs: The offline tests need Python 3.11 or later and nothing else; two register-file tests also need PyYAML, which the scanner image has, and are skipped without it. Live scans need Docker and internet access to build the scanner image and pull the images to scan.
In the extracted folder, run:
python3 -m unittest -v test_gate.py
docker build -t image-scanner .
sh scan.sh IMAGE@sha256:DIGEST ubuntu2404 cis_level1_server out12 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
The fixture is the TestResult of a real scan with OpenSCAP 1.4.4 and SSG 0.1.82, whose Ubuntu profile follows CIS v1.0.0. An image on disk cannot show running services, kernel settings or mounts; scan hosts and nodes as well.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- Docker: build secrets
- Docker: building best practices
- Docker: provenance attestations (min and max modes)
- Docker: seccomp security profiles
- OWASP Docker Security Cheat Sheet
- Kubernetes Pod Security Standards. Kubernetes v1.37.
- Sigstore: verifying signatures with cosign
- Kyverno: ImageValidatingPolicy
- NIST SP 800-190: Application Container Security Guide. September 2017.
- GHSA-69fq-xp46-6x23: Trivy supply chain compromise (CVE-2026-33634). 21 March 2026.
- containerd GHSA-fqw6-gf59-qr4w: runAsNonRoot evasion (CVE-2026-46680). 20 May 2026.
Reference information
- SDLC phase
- DevelopmentReleaseMaintenance