Saltar al contenido
EdgeServers
Blog

docker

A practical Docker image supply chain — signed, scanned, attested

Cosign, Trivy, SBOMs, and admission policies. The minimum container supply-chain setup we ship on every customer cluster.

15 de mayo de 2026 · 9 min · por Sudhanshu K.

A practical Docker image supply chain — signed, scanned, attested

"My image scans clean" is not a supply chain. A supply chain is provable lineage from the source commit to the running container — every step verifiable, every artifact signed, every transition policed.

This is the minimum supply-chain setup we ship for every managed Docker and Kubernetes customer. It's not optional, it's not "we'll do it next quarter," and it costs less than an hour of pipeline time per build. There's no excuse for not running this in 2026.

The four artifacts

For every image we build, we produce four artifacts:

  1. The image — tagged immutably (commit SHA, not latest)
  2. An SBOM (Software Bill of Materials) — SPDX or CycloneDX, listing every package and version
  3. A vulnerability scan report — Trivy or Grype, with severity and exploitability scoring
  4. A signature — Cosign over the digest, plus signed attestations linking the image to the SBOM, scan, and source commit

All four get pushed alongside the image into the same OCI registry under OCI 1.1 reference relations. The registry holds the full provenance graph. Anyone with pull access can inspect any of the artifacts.

The build pipeline

Here's the GitHub Actions excerpt we use:

permissions:
  id-token: write   # required for Cosign keyless via OIDC
  contents: read
  packages: write
  attestations: write
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: docker/setup-buildx-action@v3
    - uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
 
    - name: Build and push
      id: build
      run: |
        IMG=ghcr.io/${{ github.repository }}:${{ github.sha }}
        docker buildx build --push -t $IMG \
          --provenance=true \
          --sbom=true \
          .
        echo "image=$IMG" >> $GITHUB_OUTPUT
        echo "digest=$(crane digest $IMG)" >> $GITHUB_OUTPUT
 
    - name: Generate SBOM
      run: syft ${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }} \
        -o cyclonedx-json > sbom.cdx.json
 
    - name: Scan
      run: trivy image --format json --severity HIGH,CRITICAL \
        --exit-code 1 \
        ${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }} \
        > scan.json
 
    - name: Sign image
      run: cosign sign --yes \
        ${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }}
 
    - name: Attest SBOM
      run: cosign attest --yes \
        --predicate sbom.cdx.json --type cyclonedx \
        ${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }}
 
    - name: Attest scan
      run: cosign attest --yes \
        --predicate scan.json --type vuln \
        ${{ steps.build.outputs.image }}@${{ steps.build.outputs.digest }}

Two important details:

  • Sign the digest, not the tag. Tags are mutable; digests are content-addressed. cosign sign image:v1 is a signature you can move. cosign sign image@sha256:abc... is a signature you can't.
  • Keyless signing via Fulcio + Rekor. The signing identity is the GitHub Actions OIDC token — no long-lived signing keys to store, rotate, or leak. The signature is bound to the workflow file, the branch, and the trigger event. A bad actor with a stolen registry token can't forge a signature; they'd need to compromise GitHub Actions OIDC, which is a much taller order.

Cluster admission policy

Building the artifacts is half the job. Refusing to run anything that doesn't have them is the other half.

We use Kyverno for this. The policy refuses any pod whose image:

  • Is not signed by an expected identity (the build pipeline's workload identity)
  • Has a critical CVE in its scan attestation older than 7 days
  • Doesn't have an SBOM attestation at all
  • Wasn't built from the main branch (for production namespaces)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-signatures-and-scan
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
  - name: verify-cosign-signature
    match:
      any:
      - resources:
          kinds: [Pod]
          namespaces: ["production-*"]
    verifyImages:
    - imageReferences:
      - "ghcr.io/example/*"
      attestors:
      - entries:
        - keyless:
            issuer: "https://token.actions.githubusercontent.com"
            subject: "https://github.com/example/services/.github/workflows/release.yml@refs/heads/main"
            rekor:
              url: "https://rekor.sigstore.dev"
      verifyDigest: true
      mutateDigest: true   # rewrite tags to digests
      required: true
 
  - name: verify-sbom-attestation
    match:
      any:
      - resources:
          kinds: [Pod]
          namespaces: ["production-*"]
    verifyImages:
    - imageReferences: ["ghcr.io/example/*"]
      attestations:
      - type: "https://cyclonedx.org/bom"
        attestors:
        - entries:
          - keyless:
              issuer: "https://token.actions.githubusercontent.com"
              subject: "https://github.com/example/services/.github/workflows/release.yml@refs/heads/main"

A developer with kubectl apply rights cannot push an unsigned image into prod. The cluster will reject the pod at admission time, before it ever schedules. That's the bargain — we trade some build-time machinery for a much stronger run-time guarantee about what's actually running.

What we monitor after deploy

The supply chain is a continuous concern, not a build-time concern:

  • New CVEs against running images. The Trivy DB updates daily. We re-scan in-place every 6 hours. If a HIGH or CRITICAL CVE drops against a currently-running image, the on-call gets paged. Most CVE remediation timelines start "the day the CVE was published" — but you have to know it was published.

  • Images running in production that haven't been rebuilt in N days. A long-lived image is a stale image. We flag anything >30 days since last build. Some images are intentionally pinned (a database, for instance); those go on an explicit allowlist with a "last reviewed" date.

  • Signature verification failures in cluster audit logs. Even though we enforce signatures, we also alert on attempts to apply un-signed pods. It's a clean signal of either a misconfigured pipeline or a deliberate bypass attempt.

  • Drift between SBOM at build time and what's actually in the running container. This is the subtle one. If the image at build had package X version 1.2.3 but the running container has X 1.2.4, somebody snuck in an apt install at runtime. That's both a security and a reproducibility red flag.

We run all of these for managed Kubernetes customers as part of the standard package. The instrumentation is mostly the same regardless of cloud — the work is in wiring the alerts to actually get acted on.

Common objections (with responses)

"This is too much for a small team." The whole pipeline above is maybe 40 lines of YAML. The total build-time cost is 20-30 seconds per image. The CVE rescan loop is a small cron job. The total operational cost is trivially small; what's expensive is learning this is the right setup, then building the tooling. The good news is you can copy ours.

"We use a private registry, the threat doesn't apply." Private registries get pwned too. The 2024 Sisense breach included credentials to pull from many private container registries. Signing isn't only protection against public registry tampering — it's protection against anyone with registry write access pushing a malicious image.

"We have a vulnerability scanner, isn't that enough?" Scanning tells you what's wrong. Signing tells you what was supposed to be there. They're complementary — a scanned-but-unsigned image is no better than an unscanned-and-signed one. You need both.

"We don't run Kubernetes, we run plain Docker." The signing and scanning parts apply unchanged. The admission policy needs a different enforcement point — usually a sidecar to your container runtime, or a check in your CI/CD deploy stage. Less elegant than Kyverno, but achievable.

What we ship by default

For every new customer onboarding, we set up:

  • A reference build pipeline with the steps above
  • A Kyverno policy bundle with sensible defaults
  • A scheduled re-scan job hitting the registry every 6 hours
  • A Grafana dashboard showing signature coverage and CVE counts across the estate
  • A 30-day "soft enforcement" period where signature violations are warned but not blocked, so existing workloads aren't immediately broken — then we flip to hard enforcement once everything is signed

It's the kind of thing that's hard to retrofit but cheap to start with. If you're building out container infrastructure right now, do this on the way in — not as a panic project 18 months later. We can bootstrap this as part of your provisioning if you want to skip the rake-stepping.

Sudhanshu K. leads cybersecurity at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). She wrote her first cosign verifier in 2021 and has been refining the policy templates ever since.