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.

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.
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 prerequisitesAbout 36 minutes: 12 to read and 24 for the exercise, plus about 20 to rebuild and check the image yourself

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
Go to the guided practice

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.
VulnerableDockerfile (before)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 .

FixedDockerfileDockerfile
# 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.

Configuration.dockerignorePlain 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.

Testcheck-secrets.shShell
# 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.

pin-base.shShell
# 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/.

Scan the digest in CI, then keep scanning
Measurenode:latest, single stageDistroless, multi-stage
Size reported by docker image ls1.79 GB221 MB
OS package findings (Trivy 0.75.0)3,157 (16 critical, 297 high)31 (23 medium, 8 low)
Fixable high or critical findings3, all in npm's bundled packages0
Shell, package manager, compilerPresentAbsent
Runtime userroot (UID 0)UID 65532
Canary secrets in history or layersToken and .env foundNone
scan-gate.shShell
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"

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.

build-and-attest.shShell
# 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.

sign-verify.shShell
# 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.

Configurationrequire-signed-release-images.yamlYAML
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.

Configurationrun.shShell
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.

Testverify-runtime.shShell
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.

Tooling
JobOptions
Minimal basesDistroless, Chainguard Images, Docker Hardened Images, Red Hat UBI Micro, scratch for static binaries
Dockerfile checksdocker build --check, Hadolint
Image scanningTrivy, Grype, Docker Scout, registry scanning
SBOMBuildKit --sbom, Syft, Trivy
Signing and verificationSigstore cosign, Notation
AdmissionKyverno, Sigstore policy-controller, OPA Gatekeeper with Ratify
Digest refreshRenovate 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
Plain 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. 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.

    Expected result and reasoning

    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. 2. Evaluate the teammate's proposal

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

    Expected result and reasoning

    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. 3. Explain the version drift

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

    Expected result and reasoning

    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. 4. Choose the runtime stage

    Pick the runtime base, user and file ownership.

    Expected result and reasoning

    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. 5. Define the acceptance evidence

    List the checks that must pass for the new digest.

    Expected result and reasoning

    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.

Your deliverable: A revised Dockerfile, .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.

Node.js 22+ · no dependencies · GitHub Actions and GitLab CI YAML · 45–75 min

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:

Shell
node --test exercise.test.mjs

13 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.

OpenSCAP 1.4.4 and SSG 0.1.82 in Docker · 45–60 min

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:

Shell
python3 -m unittest -v test_gate.py
docker build -t image-scanner .
sh scan.sh IMAGE@sha256:DIGEST ubuntu2404 cis_level1_server out

12 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.

  1. Docker: build secrets
  2. Docker: building best practices
  3. Docker: provenance attestations (min and max modes)
  4. Docker: seccomp security profiles
  5. OWASP Docker Security Cheat Sheet
  6. Kubernetes Pod Security Standards. Kubernetes v1.37.
  7. Sigstore: verifying signatures with cosign
  8. Kyverno: ImageValidatingPolicy
  9. NIST SP 800-190: Application Container Security Guide. September 2017.
  10. GHSA-69fq-xp46-6x23: Trivy supply chain compromise (CVE-2026-33634). 21 March 2026.
  11. containerd GHSA-fqw6-gf59-qr4w: runAsNonRoot evasion (CVE-2026-46680). 20 May 2026.

Reference information

Download this guide as Markdown