Container Image Scanning and SBOMs
A container image is only as trustworthy as the software packed inside it. Image scanning inspects the layers of a built image for known vulnerabilities in OS packages and application dependencies, while a Software Bill of Materials (SBOM) is a machine-readable inventory of every component the image contains. Together they let a pipeline answer two different questions before an image reaches production: does this image have known security problems, and exactly what is inside it, in case that question needs answering again next month. This lesson adds vulnerability scanning and SBOM generation as a hard gate in a Docker build-and-push pipeline, building on the workflow basics from earlier lessons.
Overview / How it works
A scanner extracts the installed packages from an image’s layers, such as apt or apk entries, language lockfiles like package-lock.json or requirements.txt, and compiled binaries, then cross-references those package versions against vulnerability databases (the National Vulnerability Database, distro security advisories, and the GitHub Advisory Database). Common open-source scanners include Trivy, Grype, and Docker Scout. An SBOM generator like Syft performs the same package extraction but instead of matching against vulnerability data, it emits a structured document in a standard format, usually SPDX or CycloneDX, listing every component, its version, and its license. The SBOM is not itself a security check; it is a record you can query later. When a new critical vulnerability is disclosed for a library, you can search stored SBOMs to find every past image that shipped that library instead of re-scanning old build history.
Both steps belong between build and push. Build the image, scan it, and only continue toward the registry if the scan passes a defined severity threshold. Generate the SBOM alongside the scan and attach it to the published artifact, either as a workflow artifact, a registry attestation, or both. This ordering matters because a tag such as app:latest is mutable and can later point at different bytes, while a digest such as sha256:af3d... is an immutable hash of the exact image content. Security findings and SBOMs should always be tied to a digest, not a tag, so the record stays accurate even if the tag is reassigned later.
Syntax or workflow structure
The aquasecurity/trivy-action action scans an image reference and controls pass/fail behavior with a few key inputs: severity selects which CVE severities to report, exit-code determines whether matches fail the step, and ignore-unfixed skips vulnerabilities with no available patch, since failing a build over an unfixable issue only blocks releases without reducing risk. The anchore/sbom-action action generates an SBOM from an image or filesystem path, with format selecting SPDX or CycloneDX output. Publishing signed attestations that bind an SBOM or build provenance to a specific digest requires the actions/attest-build-provenance action, which needs id-token: write to mint a short-lived OIDC token and attestations: write to record the attestation, in addition to whatever packages: write or registry credentials are needed to push. Default workflow permissions do not include any of these, so they must be declared explicitly.
Examples
Example 1: A vulnerability gate on every push
This step scans a freshly built image and fails the job if any fixable critical or high severity vulnerability is found.
name: Scan Container Image
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build image
run: docker build -t app:${{ github.sha }} .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: app:${{ github.sha }}
format: table
severity: CRITICAL,HIGH
exit-code: '1'
ignore-unfixed: true
Expected behavior: the job fails and stops before any push step if a fixable CRITICAL or HIGH vulnerability is found in the built image. If no such vulnerability exists, or every match is unfixed with no patch available, the job continues.
Example 2: Reproducing the scan locally
Before relying on CI, it helps to run the same tools on a developer machine so the pipeline behavior is not a surprise.
# Build the image locally
docker build -t app:local .
# Scan with the Trivy CLI, matching the CI thresholds
trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed app:local
# Generate an SPDX SBOM with Syft
syft app:local -o spdx-json > sbom.spdx.json
Expected behavior: trivy image prints a table of matched vulnerabilities and exits non-zero if any CRITICAL or HIGH severity fixable issue is present, matching the exit code the CI step would produce. The syft command writes an SPDX JSON document listing every detected package and version, which can be inspected with any JSON viewer or fed into SBOM analysis tools.
Example 3: Full pipeline with a scan gate and provenance attestation
This workflow builds the image without pushing, scans and generates an SBOM against the local build, and only pushes and attests the image once the scan step has already passed.
name: Build, Scan, and Publish
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write
attestations: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image locally (no push yet)
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: local/app:scan
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: local/app:scan
severity: CRITICAL,HIGH
exit-code: '1'
ignore-unfixed: true
- name: Generate SBOM
uses: anchore/sbom-action@v0.17.0
with:
image: local/app:scan
format: spdx-json
output-file: sbom.spdx.json
- name: Push image (only reached if the scan passed)
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Attest build provenance
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: sbom.spdx.json
Expected behavior: if the scan step fails, the workflow stops there and no image, SBOM, or attestation is ever pushed to the registry. If it passes, the image is rebuilt with push: true, the resulting digest is captured from steps.push.outputs.digest, and both the SBOM artifact and a signed provenance attestation are tied to that exact immutable digest rather than the mutable tag.
Step by step
- Check out the repository and authenticate to the registry using a scoped credential such as
secrets.GITHUB_TOKEN. - Build the image locally with
load: trueso it is available to the Docker daemon on the runner without being pushed anywhere yet. - Run the scanner against that local image and set an explicit
severityandexit-codeso the step actually fails the job on real findings. - Generate the SBOM from the same local image, before push, so the recorded component list matches what was scanned.
- Only if the scan step succeeded does the workflow reach the push step, which rebuilds (or reuses build cache) and pushes the image, producing a content digest as output.
- Attest build provenance against that digest, not the tag, using the OIDC-backed attestation action.
- Upload the SBOM as a workflow artifact so it is retrievable later without needing to re-pull or re-scan the image.
Common Mistakes
| Mistake | Correction |
|---|---|
| Pushing the image first and scanning afterward, so a vulnerable image is already pullable from the registry by the time the scan step fails. | Build with load: true and scan the local image before any push step runs, so nothing reaches the registry until the gate passes. |
Adding a scan step without setting exit-code or severity, so the action reports findings in the log but exits 0 and the job shows green regardless of what it found. |
Explicitly set exit-code: '1' and a severity list such as CRITICAL,HIGH so matching vulnerabilities actually fail the job. |
Tying scan results and SBOMs to a floating tag like app:latest, which can later be reassigned to different image content, making the stored security record inaccurate. |
Capture and record the immutable digest from the push step output, and reference that digest in SBOM metadata and attestations instead of the tag. |
Best Practices
- Pin scanning and SBOM actions to a specific release tag or commit SHA rather than a floating major version, since a compromised or altered action version runs with your workflow’s permissions.
- Set
ignore-unfixed: truefor routine gating so builds are not blocked by vulnerabilities with no available patch, but track those separately for awareness. - Grant only the permissions each job needs:
contents: readalways,packages: writeonly on jobs that push, andid-token: writeplusattestations: writeonly on jobs that sign or attest. - Generate the SBOM from the same build that was scanned and pushed, not a separate rebuild, so the component list matches the exact bytes that shipped.
- Store SBOMs somewhere queryable, such as workflow artifacts or a registry attestation, so a newly disclosed vulnerability can be checked against past releases without re-scanning old images.
- Reference images by digest in deployment manifests, not by mutable tag, so what gets deployed is provably what was scanned.
Practice Exercises
- Add a Trivy scan step to an existing build workflow with
severity: CRITICAL,HIGHandexit-code: '1', then intentionally add a dependency with a known CVE to confirm the job fails. - Generate an SBOM with
anchore/sbom-actionin CycloneDX format instead of SPDX, and upload it as a workflow artifact with a retention period of your choosing. - Rewrite a workflow that currently pushes before scanning so that it builds locally with
load: true, scans, and only pushes after the scan step succeeds. - Add an
actions/attest-build-provenancestep to a push job, wire the required permissions, and verify the attestation is visible on the pushed image’s registry page.
Summary
Vulnerability scanning and SBOM generation turn a Docker pipeline from \”it built successfully\” into \”it built successfully and we know what is inside it and whether that is safe.\” The ordering matters as much as the tools: build locally, scan and generate the SBOM before anything is pushed, gate the push on the scan result, and bind both the SBOM and any provenance attestation to the immutable digest rather than a tag. With minimal explicit permissions and pinned action versions, this becomes a repeatable gate that catches known-vulnerable images before they ever reach a registry a deployment could pull from.
