Container Image Security: Scanning, Signing, and Supply Chain Integrity

Philip Rehberger Mar 25, 2026 8 min read

Container image security goes far beyond vulnerability scanning. Learn how to build a defense-in-depth approach covering base image hygiene, vulnerability scanning in CI, image signing, and runtime admission controls.

Container Image Security: Scanning, Signing, and Supply Chain Integrity

In 2021, a malicious image uploaded to Docker Hub was pulled over 10 million times before it was detected. The SolarWinds attack demonstrated that supply chain compromise is a real and sophisticated threat vector. Container image security is no longer just "run a scanner" — it's a pipeline discipline that spans from base image selection through runtime enforcement.

Here's how to build a layered approach that catches problems at every stage.

The Container Supply Chain

Understanding what you're protecting starts with mapping where images come from:

Upstream OS vendor
  → Base image (ubuntu:22.04, node:20-alpine)
    → Your application layers
      → CI builds and pushes image
        → Registry stores image
          → Kubernetes pulls image
            → Runtime executes container

Vulnerabilities and tampering can enter at any stage. Your security controls need to cover the entire chain.

Base Image Selection

The base image is the foundation of your security posture. A bloated base image brings thousands of packages — each a potential vulnerability.

Prefer Minimal Base Images

# Avoid: Full OS base brings ~300 packages, many unnecessary
FROM ubuntu:22.04

# Better: Debian slim — smaller surface area
FROM debian:12-slim

# Best for most apps: Distroless — no shell, no package manager
FROM gcr.io/distroless/java17-debian12

# Smallest possible: Scratch (static binaries only)
FROM scratch
COPY myapp /myapp
CMD ["/myapp"]

Distroless images contain only the application and its runtime dependencies. No shell means no shell injection. No package manager means no way to download additional tools. Many CVEs in base images are irrelevant to distroless because the vulnerable component simply isn't there.

Multi-Stage Builds to Minimize Runtime Image

# Build stage: full SDK needed
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server

# Runtime stage: only the binary
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

The build stage has the full Go SDK, git, and build tools. The runtime image has just the compiled binary. The attack surface shrinks from ~1000MB to ~10MB.

Pin Base Image Digests

Tags are mutable. node:20-alpine today might not be the same image next week. For production, pin by digest:

# Mutable tag — can change without warning
FROM node:20-alpine

# Immutable digest — this exact image forever
FROM node:20-alpine@sha256:0a2dfbc3d5d99c1a40b0e5f11d0cb0aa6ef1e0be069c12f34f9e42d8a73de44d

Your CI pipeline should periodically update these digests as upstream images are patched.

Vulnerability Scanning in CI

Scan images before they can reach production. Multiple scanners are better than one because each has different vulnerability databases and detection capabilities.

Trivy: The Workhorse

Trivy is fast, free, and comprehensive. It scans OS packages, language dependencies, and IaC files:

# .github/workflows/security.yml
name: Container Security
on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1  # Fail pipeline on critical/high findings

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

SARIF upload integrates results directly into GitHub's Security tab, so developers see vulnerabilities in context.

Scanning Non-OS Dependencies

Trivy also scans language package managers, which is where many vulnerabilities live:

# Scan a directory for all vulnerability types
trivy fs --scanners vuln,secret,misconfig ./

# Output:
# package.json (npm)
# ├── lodash 4.17.15 - CVE-2021-23337 (HIGH)
# ├── axios 0.21.1  - CVE-2021-3749  (HIGH)
# └── ...
#
# Dockerfile
# ├── Running as root
# └── No HEALTHCHECK defined

Secrets Detection

Accidentally committed secrets are a critical risk. Scan for them before the image is pushed:

# Trivy secret scanning
trivy image --scanners secret myapp:latest

# Dedicated secret scanning with Gitleaks
docker run --rm -v $(pwd):/repo zricethezav/gitleaks:latest detect \
  --source /repo \
  --report-format sarif \
  --report-path gitleaks-report.sarif

Common secrets found in container images: AWS credentials in build ARGs, database connection strings baked into images, API keys in environment variable defaults.

Image Signing with Cosign

Scanning tells you what's vulnerable. Signing tells you what's authentic. Cosign (from the Sigstore project) makes it easy to sign and verify images.

Signing Images in CI

# In GitHub Actions, using keyless signing (OIDC-based)
- name: Install Cosign
  uses: sigstore/cosign-installer@v3

- name: Sign image
  env:
    COSIGN_EXPERIMENTAL: "true"  # Enables keyless mode
  run: |
    cosign sign \
      --yes \
      myregistry.io/myapp:${{ github.sha }}

Keyless signing uses OIDC tokens from the CI provider (GitHub, GitLab, etc.) as the identity. No long-lived keys to manage. The signature is stored in Rekor, a public transparency log.

Verifying Images Before Deployment

# Verify an image was signed by your CI pipeline
cosign verify \
  --certificate-identity-regexp="https://github.com/myorg/myapp" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  myregistry.io/myapp:1.2.3

# Output includes:
# Verification for myregistry.io/myapp:1.2.3 --
# The following checks were performed on each of these signatures:
#   - The cosign claims were validated
#   - Existence of the claims in the transparency log was verified offline
#   - The code-signing certificate claims were validated

If verification fails, the image was not built by your CI pipeline. Either it was tampered with, or someone pushed it manually.

Software Bill of Materials (SBOM)

An SBOM is a complete inventory of everything in your container image. Required by many compliance frameworks and increasingly by customers.

# Generate SBOM with Syft
synft packages myapp:latest -o spdx-json > sbom.spdx.json

# Attach SBOM to image in registry
cosign attach sbom \
  --sbom sbom.spdx.json \
  --type spdx \
  myregistry.io/myapp:${{ github.sha }}

# Later: download and inspect SBOM for any image
cosign download sbom myregistry.io/myapp:1.2.3

With an SBOM attached, when a new zero-day vulnerability is announced (Log4j style), you can query your registry to find every image that contains the affected component — in minutes, not days.

Registry Security

Admission Control with OPA/Gatekeeper

Prevent unsigned or unscanned images from running in your cluster using Kubernetes admission controllers:

# Gatekeeper constraint: require signed images
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireSignedImages
metadata:
  name: require-signed-images
spec:
  match:
    kinds:
      - apiGroups: ["apps"]
        kinds: ["Deployment", "StatefulSet", "DaemonSet"]
    excludedNamespaces:
      - kube-system
  parameters:
    allowedIssuers:
      - "https://token.actions.githubusercontent.com"
    allowedSubjectPattern: "https://github.com/myorg/.*"

Registry Policies

Configure your container registry with policies that prevent insecure practices:

// AWS ECR registry policy example
{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep last 30 tagged images",
      "selection": {
        "tagStatus": "tagged",
        "tagPrefixList": ["release-"],
        "countType": "imageCountMoreThan",
        "countNumber": 30
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "Expire untagged images after 7 days",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 7
      },
      "action": { "type": "expire" }
    }
  ]
}

Enable ECR scan-on-push to automatically scan every image when it arrives in the registry.

Runtime Security

Even with clean images, runtime security matters. Containers can be exploited through application vulnerabilities.

Security Contexts

Run containers with the minimum required privileges:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: api
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL
          add:
            - NET_BIND_SERVICE  # Only if binding to port < 1024
      volumeMounts:
        - name: tmp
          mountPath: /tmp  # Writable tmp for read-only root
  volumes:
    - name: tmp
      emptyDir: {}

readOnlyRootFilesystem: true means attackers who compromise your container can't write malware or modify files on disk. They're limited to what's in memory.

Falco for Runtime Threat Detection

Falco watches system calls and alerts on suspicious behavior — even from otherwise clean images:

# Custom Falco rule
- rule: Unexpected Network Connection
  desc: Detect unexpected outbound connections from api containers
  condition: >
    outbound and
    container.name = "api" and
    not fd.sip in (allowed_ip_ranges)
  output: >
    Unexpected connection from api container
    (user=%user.name command=%proc.cmdline
     connection=%fd.name container=%container.name)
  priority: WARNING

A container that normally only accepts inbound connections suddenly making outbound connections to an unusual IP is a strong indicator of compromise.

Building the Full Pipeline

Here's the complete security gate sequence:

1. Code commit
   → Secret scanning (Gitleaks)
   → SAST scanning (CodeQL, Semgrep)

2. Image build
   → Multi-stage build (minimize attack surface)
   → Pin base image digests

3. Pre-push scanning
   → Vulnerability scan (Trivy)
   → Fail on CRITICAL/HIGH CVEs
   → Secrets in image scan

4. Push to registry
   → Registry scans on push (ECR, Artifact Registry)
   → SBOM generation and attachment
   → Image signing (Cosign)

5. Deploy to cluster
   → Admission controller verifies signature
   → Policy enforcement (no root, read-only FS)
   → Runtime monitoring (Falco)

6. Continuous
   → Periodic rescans of deployed images
   → Alert on newly discovered CVEs in running containers

Each layer catches different problems. The goal isn't perfect prevention — it's making the attacker's job as hard as possible at every stage.

Building something that needs to scale? We help teams architect systems that grow with their business. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.