Back to Containers & Runtime Environments Mastery Series

Part 17: Supply Chain Security & Secrets

May 14, 2026 Wasil Zafar 24 min read

Your container's security is only as strong as its weakest dependency. Supply chain attacks — SolarWinds, Log4Shell, Codecov — demonstrated that attackers increasingly target the build pipeline rather than the running application. This article equips you with SBOMs for dependency transparency, provenance attestations for build verification, cryptographic signing for image integrity, and secret management patterns that keep credentials out of image layers.

Table of Contents

  1. The Supply Chain Problem
  2. Software Bill of Materials
  3. Image Provenance
  4. Signing with cosign
  5. Notary v2 & DCT
  6. Trusted Registries & Policies
  7. The Secret Problem
  8. Docker Secrets (Swarm)
  9. Build-Time Secrets
  10. External Secret Managers
  11. Environment Variable Security
  12. Exercises
  13. Conclusion & Next Steps

The Supply Chain Problem

Modern containers don't run code you wrote — they run code your dependencies wrote. A typical production container image contains thousands of components from hundreds of authors. Each is a potential attack vector.

The Dependency Iceberg

Container Image Dependency Chain
flowchart TB
    A["Your Application Code
(~5% of image)"] --> B["Direct Dependencies
(npm, pip, go modules)"] B --> C["Transitive Dependencies
(deps of your deps)"] C --> D["System Libraries
(glibc, openssl, zlib)"] D --> E["Base Image OS Packages
(apt/apk packages)"] E --> F["Linux Kernel Interfaces
(shared with host)"] style A fill:#f0f9f9,stroke:#3B9797 style B fill:#f8f9fa,stroke:#132440 style C fill:#f8f9fa,stroke:#132440 style D fill:#fff5f5,stroke:#BF092F style E fill:#fff5f5,stroke:#BF092F style F fill:#fff5f5,stroke:#BF092F

Supply chain attacks that shook the industry:

Attack Year Vector Impact
SolarWinds (Sunburst)2020Compromised build pipeline18,000+ organizations, US government agencies
Codecov2021Modified Docker image in CIThousands of repos' secrets exfiltrated
ua-parser-js2021NPM package hijackingCryptominer + credential stealer in 8M weekly downloads
Log4Shell2021Transitive dependency vulnerabilityVirtually every Java application worldwide
xz-utils2024Long-term maintainer social engineeringBackdoor in SSH authentication (caught before widespread deployment)
Critical Lesson: Log4Shell (CVE-2021-44228) was a transitive dependency — most affected applications didn't directly depend on Log4j. It was pulled in by frameworks, logging bridges, and other libraries. Without an SBOM, most organizations couldn't even answer the question: "Are we affected?"

Software Bill of Materials (SBOM)

An SBOM is a machine-readable inventory of every component in your software — package names, versions, licenses, and relationships. It's the "ingredient list" for your container image.

Two standard formats dominate:

Format Maintained By Strengths Typical Use
SPDXLinux FoundationISO standard (ISO/IEC 5962:2021), license-focusedCompliance, legal
CycloneDXOWASPSecurity-focused, VEX support, lightweightSecurity scanning, vulnerability management

Generating SBOMs

# Generate SBOM with Syft (by Anchore) — the most popular SBOM generator
# Install Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# Generate SPDX SBOM from a container image
syft myapp:latest -o spdx-json > sbom-spdx.json

# Generate CycloneDX SBOM
syft myapp:latest -o cyclonedx-json > sbom-cdx.json

# Generate SBOM from a directory (source code)
syft dir:./my-project -o spdx-json > source-sbom.json

# Generate SBOM with Trivy
trivy image --format spdx-json --output sbom.json myapp:latest

# Attach SBOM to an OCI image (using cosign)
cosign attach sbom --sbom sbom-cdx.json myregistry.io/myapp:v1.0.0

# Verify and download attached SBOM
cosign verify-attestation --type spdxjson myregistry.io/myapp:v1.0.0

Example SBOM output (CycloneDX, abbreviated):

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
  "version": 1,
  "metadata": {
    "component": {
      "type": "container",
      "name": "myapp",
      "version": "v1.0.0"
    }
  },
  "components": [
    {
      "type": "library",
      "name": "express",
      "version": "4.18.2",
      "purl": "pkg:npm/express@4.18.2",
      "licenses": [{ "license": { "id": "MIT" } }]
    },
    {
      "type": "library",
      "name": "openssl",
      "version": "3.0.13",
      "purl": "pkg:deb/debian/openssl@3.0.13-1~deb12u1",
      "licenses": [{ "license": { "id": "Apache-2.0" } }]
    }
  ]
}
Key Insight: SBOMs are most valuable during incident response. When the next Log4Shell hits, teams with SBOMs can query "which of our images contain package X at version Y?" in seconds, rather than spending days scanning every image. Generate SBOMs in CI/CD and store them alongside images.

Image Provenance & Attestations

Provenance answers: "Where was this image built, from what source code, by which pipeline?" It provides verifiable evidence of an image's origin, preventing scenarios where an attacker builds a malicious image outside your CI/CD and pushes it to your registry.

The SLSA framework (Supply-chain Levels for Software Artifacts) defines progressive security levels:

SLSA Level Requirements What It Prevents
Level 1Build process exists and generates provenanceUnknown build origins
Level 2Hosted build service, authenticated provenanceTampered provenance metadata
Level 3Hardened build platform, non-falsifiable provenanceCompromised build steps
Level 4Two-party review, hermetic buildsInsider threats, dependency confusion
# Generate SLSA provenance with GitHub Actions
# The slsa-github-generator creates non-falsifiable provenance
# See: github.com/slsa-framework/slsa-github-generator

# Verify image provenance with cosign
cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp='https://github.com/myorg/.*' \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  myregistry.io/myapp:v1.0.0

# View provenance attestation content
cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp='.*' \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  myregistry.io/myapp:v1.0.0 | jq '.payload' | base64 -d | jq .

Signing Images with cosign

cosign (part of Sigstore) provides two signing modes: traditional key-based signing and modern keyless signing using OIDC identity providers. Keyless signing is preferred because it eliminates key management complexity.

cosign Keyless Signing Flow
sequenceDiagram
    participant Dev as Developer/CI
    participant OIDC as Identity Provider
(GitHub, Google) participant Fulcio as Fulcio CA participant Rekor as Rekor Transparency Log participant Reg as Container Registry Dev->>OIDC: Authenticate (OAuth2) OIDC-->>Dev: OIDC Token (identity proof) Dev->>Fulcio: Request signing certificate
(short-lived, OIDC token) Fulcio-->>Dev: X.509 Certificate (10 min validity) Dev->>Dev: Sign image digest with certificate Dev->>Rekor: Record signature in transparency log Dev->>Reg: Attach signature to image Note over Rekor: Immutable public record
of who signed what, when
# Keyless signing in CI/CD (GitHub Actions)
# No keys to manage — identity comes from the CI platform
cosign sign myregistry.io/myapp@sha256:abc123...
# Fulcio issues short-lived certificate based on GitHub OIDC token
# Signature recorded in Rekor transparency log

# Verify with identity constraints
cosign verify \
  --certificate-identity='https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main' \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  myregistry.io/myapp@sha256:abc123...

# Sign with attestations (SBOM + provenance in one step)
cosign attest --predicate sbom.json --type cyclonedx myregistry.io/myapp:v1.0.0
cosign attest --predicate provenance.json --type slsaprovenance myregistry.io/myapp:v1.0.0

# Complete CI/CD signing workflow (GitHub Actions)
# .github/workflows/sign.yml
# steps:
#   - uses: sigstore/cosign-installer@v3
#   - run: cosign sign ${IMAGE}@${DIGEST}
#     env:
#       COSIGN_EXPERIMENTAL: "true"  # Enable keyless

Notary v2 & Docker Content Trust

Notary v2 (now part of the CNCF Notation project) provides a different trust model than cosign, using traditional PKI with delegation roles suitable for enterprise environments:

Feature cosign (Sigstore) Notation (Notary v2)
Trust modelTransparency log (Rekor)Traditional PKI / X.509
Key managementKeyless (OIDC) or traditional keysKey hierarchy with delegations
Certificate authorityFulcio (short-lived certs)Enterprise CA or self-managed
Threshold signingNot nativeSupported (M-of-N signing)
Audit trailPublic Rekor logRegistry-attached signatures
EcosystemKubernetes, GitHub Actions, GitLabAzure ACR, AWS Signer
Best forOpen source, cloud-native CI/CDEnterprises with existing PKI
# Notation (Notary v2) — enterprise image signing
# Install notation CLI
curl -Lo notation.tar.gz https://github.com/notaryproject/notation/releases/download/v1.1.0/notation_1.1.0_linux_amd64.tar.gz
tar xzf notation.tar.gz -C /usr/local/bin notation

# Add a signing key
notation key add --name mykey --plugin azure-kv --id https://myvault.vault.azure.net/keys/signing-key/abc123

# Sign an image
notation sign myregistry.io/myapp@sha256:abc123...

# Verify an image
notation verify myregistry.io/myapp@sha256:abc123...

# List signatures on an image
notation list myregistry.io/myapp@sha256:abc123...

Trusted Registries & Policies

Signing images is only half the equation — you must also enforce that only signed images can run. Admission controllers in Kubernetes reject unsigned or untrusted images before they're scheduled.

# Kyverno policy: Only allow signed images from trusted registry
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "myregistry.io/*"
          attestors:
            - entries:
                - keyless:
                    issuer: "https://token.actions.githubusercontent.com"
                    subject: "https://github.com/myorg/*"
                    rekor:
                      url: "https://rekor.sigstore.dev"
# OPA/Gatekeeper constraint: Deny unsigned images
# constraint.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: RequireImageSignature
metadata:
  name: require-signed-images
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces: ["production"]
  parameters:
    registries:
      - "myregistry.io"
    cosignVerification:
      required: true
      keyRef: "cosign-public-key"

The Secret Management Problem

Secrets (API keys, database passwords, certificates) are the most common security mistake in container deployments. They leak through multiple channels:

Security Demonstration
How Secrets Leak from Container Images

Every layer in a Docker image is permanent and readable by anyone with pull access. Even if you delete a secret in a later layer, it persists in the image history:

# BAD: Secret is permanently stored in image layer
FROM node:20-alpine
ENV DATABASE_URL=postgres://admin:s3cr3t@db.example.com/prod
COPY . /app

# Even if you try to "delete" it later:
RUN unset DATABASE_URL
# The ENV is still visible in the image metadata!

# Anyone can see it:
docker inspect myapp:latest | jq '.[0].Config.Env'
# ["DATABASE_URL=postgres://admin:s3cr3t@db.example.com/prod"]

# Or extract from image history:
docker history --no-trunc myapp:latest
# Shows the ENV instruction with full secret value

Lesson: Never use ENV, ARG, or COPY for secrets. They are permanently embedded in image layers and visible to anyone who can pull the image.

secrets image-layers data-leak

Docker Secrets (Swarm Mode)

Docker Secrets is a built-in secret management system for Docker Swarm. Secrets are encrypted at rest, transmitted over TLS, and mounted as in-memory files at /run/secrets/ — never touching disk or image layers.

# Create a secret from a file
echo "my-super-secret-password" | docker secret create db_password -

# Create from a file
docker secret create tls_cert ./server.crt

# List secrets
docker secret ls
# ID                          NAME          CREATED
# qdpvj3k4x3t0z7k2hf9lm1n8a  db_password   2 minutes ago
# r8k3j5l2m1n4p6q9s0t7v8w3x  tls_cert      1 minute ago

# Use secrets in a service
docker service create \
  --name mydb \
  --secret db_password \
  --secret tls_cert \
  -e DB_PASSWORD_FILE=/run/secrets/db_password \
  postgres:16

# Inside the container, the secret is a file:
# /run/secrets/db_password (contains "my-super-secret-password")
# Mounted as tmpfs — never written to disk
# docker-compose.yml with secrets
version: "3.8"
services:
  webapp:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password
      - API_KEY_FILE=/run/secrets/api_key

secrets:
  db_password:
    external: true    # Pre-created with docker secret create
  api_key:
    file: ./secrets/api_key.txt    # From local file (dev only)
Note: Docker Secrets require Swarm mode. For standalone Docker (non-Swarm), use BuildKit secrets for build-time credentials and external secret managers (Vault, AWS Secrets Manager) for runtime secrets.

Build-Time Secrets

BuildKit's --mount=type=secret provides build-time secrets that are never committed to image layers. The secret is available during the RUN instruction but leaves no trace in the final image.

# syntax=docker/dockerfile:1.4

FROM node:20-alpine

WORKDIR /app
COPY package.json package-lock.json ./

# Mount secret during npm install (for private registry auth)
# The secret is available at /run/secrets/npmrc during this RUN only
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --only=production

COPY . .

USER node
CMD ["node", "server.js"]
# Build with the secret (passed at build time, never stored in image)
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:latest .

# Verify the secret is NOT in the image
docker history --no-trunc myapp:latest
# No trace of .npmrc content in any layer

# Multiple secrets example
docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  --secret id=github_token,src=./github-token.txt \
  -t myapp:latest .

# In Dockerfile:
# RUN --mount=type=secret,id=github_token \
#     GITHUB_TOKEN=$(cat /run/secrets/github_token) \
#     go install private-repo/package@latest
# SSH agent forwarding for private Git repos (BuildKit)
# syntax=docker/dockerfile:1.4

FROM golang:1.22 AS builder

WORKDIR /app

# Mount SSH agent socket — clone private repos without embedding keys
RUN --mount=type=ssh \
    git clone git@github.com:myorg/private-lib.git /deps/private-lib

COPY . .
RUN go build -o /server .

FROM gcr.io/distroless/static
COPY --from=builder /server /server
CMD ["/server"]
# Build with SSH agent forwarding
docker build --ssh default -t myapp:latest .

External Secret Managers

For production deployments, external secret managers provide centralized secret storage with access control, rotation, audit logging, and dynamic credential generation.

Solution Integration Pattern Rotation Best For
HashiCorp VaultSidecar agent, init container, CSI driverDynamic secrets, auto-rotationMulti-cloud, on-premises
AWS Secrets ManagerIAM roles, ECS task roles, CSI driverBuilt-in rotation lambdasAWS-native workloads
Azure Key VaultManaged identity, CSI driver, env injectionBuilt-in rotation policiesAzure-native workloads
GCP Secret ManagerWorkload identity, CSI driverVersion-based rotationGCP-native workloads
Kubernetes SecretsVolume mounts, env varsManual (use External Secrets Operator)Basic Kubernetes workloads
# HashiCorp Vault integration example
# 1. Enable database secrets engine (dynamic credentials)
vault secrets enable database
vault write database/config/mydb \
  plugin_name=postgresql-database-plugin \
  connection_url="postgresql://{{username}}:{{password}}@db:5432/mydb" \
  allowed_roles="app-role"

# 2. Create a role that generates short-lived credentials
vault write database/roles/app-role \
  db_name=mydb \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';" \
  default_ttl="1h" \
  max_ttl="24h"

# 3. Application requests dynamic credentials (auto-expires in 1 hour)
vault read database/creds/app-role
# Key                Value
# lease_id           database/creds/app-role/abc123
# username           v-app-role-xyz789
# password           A1B2C3D4-temp-password

Environment Variable Security

Environment variables are the most common (and most dangerous) way to pass secrets to containers. They're visible through multiple channels:

# Environment variables are VISIBLE to anyone with Docker access
docker inspect mycontainer | jq '.[0].Config.Env'
# ["DATABASE_URL=postgres://admin:password@db:5432/prod"]

# Also visible in /proc inside the container
docker exec mycontainer cat /proc/1/environ | tr '\0' '\n'
# DATABASE_URL=postgres://admin:password@db:5432/prod

# And in docker compose config output
docker compose config
# Shows all interpolated environment variables

# Leaked in crash dumps, debug logs, and error reports
# Application error: "connection failed to postgres://admin:password@..."

Safer alternatives to plain environment variables:

Approach Visibility Risk Rotation When to Use
ENV in DockerfileVisible in image history foreverRequires rebuildNever for secrets
docker run -eVisible in docker inspectRequires restartNon-sensitive config only
Docker Secrets (/run/secrets/)File-based, not in inspectService updateSwarm deployments
Vault sidecar injectionIn-memory only, short-livedAutomaticProduction (any platform)
CSI Secret StoreMounted as volume, not in specSync with external managerKubernetes production
Best Practice: Use the _FILE convention. Instead of DB_PASSWORD=secret, set DB_PASSWORD_FILE=/run/secrets/db_password. Your application reads the secret from the file path. This pattern works with Docker Secrets, Kubernetes Secrets, and CSI volumes, keeping secrets out of the process environment entirely.

Exercises

Exercise 1: Generate an SBOM for python:3.11 using Syft. Then query the SBOM to find all packages with known CVEs by piping the output to grype sbom:./sbom.json.
Exercise 2: Build a Docker image that requires a private NPM package. Use BuildKit's --mount=type=secret for the .npmrc file. Verify with docker history that the token doesn't appear in any layer.
Exercise 3: Set up cosign keyless signing in a GitHub Actions workflow. Build, sign, and push an image to GHCR. Then verify the signature from a different machine, constraining the certificate identity to your specific workflow file.
Exercise 4: Create a Docker Compose application that uses secrets (via the secrets: top-level key) instead of environment variables. Implement the _FILE convention in your application to read secrets from /run/secrets/.

Conclusion & Next Steps

Supply chain security closes the loop on container security: Part 15 secured the image, Part 16 hardened the runtime, and this article secured the pipeline that produces and distributes those images.

  • SBOMs provide visibility into every component, enabling rapid incident response when new CVEs emerge
  • Provenance attestations prove where images were built, preventing unauthorized builds from reaching production
  • Image signing (cosign/Notation) guarantees integrity and authenticity with cryptographic verification
  • Admission policies (Kyverno, OPA) enforce that only signed, verified images can deploy
  • Secret management keeps credentials out of image layers, environment variables, and version control
  • BuildKit secrets provide build-time credentials that never persist in the final image

Together, Parts 15-17 form a complete container security posture: secure images, hardened runtime, and verified supply chain. Next, we shift from single containers to multi-container applications with Docker Compose.

Next in the Series

In Part 18: Docker Compose, we'll compose multi-container applications using declarative YAML files — defining services, networks, volumes, and dependencies in a single configuration that brings up entire application stacks with one command.