Container Image Security — Distroless, SBOM, and Supply Chain Hardening

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Container images are attack surfaces. Most production images inherit shells, package managers, and development tools that serve no purpose at runtime but provide attackers with everything they need to move laterally after a breach. This post covers building minimal, hardened images with multi-stage builds and distroless bases, scanning for known CVEs with Trivy, generating software bills of materials with Syft, signing images cryptographically with Cosign, and enforcing image signing policies with Kubernetes admission controllers.

Multi-Stage Builds for Minimal Attack Surface

Multi-stage builds separate the build environment from the runtime environment. The final image contains only what is needed to run the application.

A single-stage build includes compilers, build tools, source code, and development headers in production — hundreds of megabytes of attack surface:

# BAD: build tools end up in production image
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y build-essential python3 python3-dev git
WORKDIR /app
COPY . .
RUN python3 setup.py build
ENTRYPOINT ["python3", "app.py"]

A multi-stage build keeps build tools out of the production image:

# Builder stage: install deps and compile
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY src/ .
 
# Runtime stage: only runtime artifacts
FROM python:3.11-slim
RUN useradd -m -u 1000 appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local
COPY --from=builder --chown=appuser:appuser /build /app
USER appuser
EXPOSE 8080
ENTRYPOINT ["python", "-u", "app.py"]

For Go applications, the final image can use scratch — literally empty:

FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .
 
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]

The scratch-based Go image contains only the binary and TLS certificates — nothing else.

Distroless Images

Google distroless images contain only the application runtime. No shell, no package manager, no curl or wget. An attacker who gains code execution in a distroless container has almost nothing to work with.

FROM node:20 AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ ./src/
 
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/src ./src
EXPOSE 8080
ENTRYPOINT ["/nodejs/bin/node", "src/index.js"]

Distroless images are 5-10x smaller than full Ubuntu bases and have dramatically fewer CVEs. The tradeoff is that debugging inside a running container requires attaching a debug sidecar rather than opening a shell. This is a reasonable trade for production workloads.

Available distroless bases from Google:

  • gcr.io/distroless/base-debian12 — minimal glibc runtime
  • gcr.io/distroless/nodejs20-debian12 — Node.js runtime
  • gcr.io/distroless/python3-debian12 — Python 3 runtime
  • gcr.io/distroless/java21-debian12 — JRE runtime

Running as Non-Root User

Containers that run as root give an attacker root access to the container and potentially the host. Always create and use a non-root user:

FROM debian:12-slim
RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser
WORKDIR /app
COPY --chown=appuser:appuser app /app/app
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app/app"]

Enforce non-root at the Kubernetes level so that a misconfigured image cannot bypass the requirement:

apiVersion: v1
kind: Pod
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: my-app:v1.2.3
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: [ALL]

Vulnerability Scanning with Trivy

Trivy scans images for known CVEs from NVD, GitHub Advisory, and OS package databases. Integrate it into CI to block builds with critical vulnerabilities:

# Scan and fail on HIGH or CRITICAL vulnerabilities
trivy image --exit-code 1 --severity HIGH,CRITICAL my-app:v1.2.3
 
# Output as SARIF for GitHub Security tab
trivy image --format sarif --output trivy-results.sarif my-app:v1.2.3

GitHub Actions integration:

- name: Scan image with Trivy
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: my-app:${{ github.sha }}
    format: sarif
    output: trivy-results.sarif
    severity: HIGH,CRITICAL
 
- name: Upload to GitHub Security
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: trivy-results.sarif

Set exit-code: 1 in production pipelines so that new HIGH or CRITICAL CVEs block deployment until the base image is updated or the package is upgraded.

Software Bill of Materials (SBOM) with Syft

An SBOM documents every software component in an image — OS packages, language packages, and direct dependencies. SBOMs are required for SOC 2 and SLSA compliance and essential for responding quickly to new CVEs.

# Generate CycloneDX SBOM
syft my-app:v1.2.3 -o cyclonedx-json > sbom.cyclonedx.json
 
# Generate SPDX SBOM
syft my-app:v1.2.3 -o spdx-json > sbom.spdx.json
 
# Scan SBOM for vulnerabilities with Grype
grype sbom:sbom.cyclonedx.json --fail-on high

Store the SBOM as a build artifact alongside each release. When a new CVE is published (for example, in OpenSSL), you can immediately query which releases contain the affected package rather than scanning every image retrospectively.

Image Signing with Cosign

Cosign signs images cryptographically. Only images signed with your key can be deployed — unsigned images are rejected at the cluster boundary.

# Generate a key pair
cosign generate-key-pair
 
# Sign an image (stores signature in the registry alongside the image)
cosign sign --key cosign.key my-app:v1.2.3
 
# Verify a signature
cosign verify --key cosign.pub my-app:v1.2.3

Sign automatically in CI after a successful build:

- uses: sigstore/cosign-installer@v3
- name: Sign image
  run: cosign sign --yes --key env://COSIGN_PRIVATE_KEY my-app:${{ github.sha }}
  env:
    COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}

Keyless signing via Sigstore ties the signature to a verifiable OIDC identity (GitHub Actions job) without managing a private key.

Admission Controller to Block Unsigned Images

Signing images is only useful if unsigned images cannot reach the cluster. A Kyverno policy enforces this at admission time:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-signature
    match:
      resources:
        kinds: [Pod]
    verifyImages:
    - imageReferences:
      - "registry.company.com/*"
      attestors:
      - count: 1
        entries:
        - keys:
            publicKeys: |
              -----BEGIN PUBLIC KEY-----
              MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
              -----END PUBLIC KEY-----

Any Pod that references an unsigned image from your registry will be rejected before it is scheduled.

Key Takeaways

  • Multi-stage Dockerfiles keep build tools, compilers, and source code out of the production image — the final stage should contain only what is needed to run the application.
  • Distroless images remove the shell, package manager, and all non-runtime binaries, reducing CVE exposure by 80-90% compared to full OS base images.
  • Running containers as root gives an attacker root access; always create a non-root user in the Dockerfile and enforce runAsNonRoot: true in Kubernetes security contexts.
  • Trivy scans images against CVE databases and can be configured to block CI/CD pipelines when HIGH or CRITICAL vulnerabilities are detected.
  • SBOMs generated with Syft document every component in an image, enabling rapid impact assessment when new CVEs are published.
  • Cosign signs images cryptographically so that only images produced by your CI pipeline can be deployed to production.
  • Kyverno admission controllers reject unsigned images before they are scheduled, making image signing policy enforceable at the cluster boundary.
  • Automate base image updates with Renovate or Dependabot — a new base image should trigger a PR, tests, and automatic merge if tests pass.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro