Container images are the deployment unit of modern infrastructure, but they're also black boxes. You pull node:18-alpine from Docker Hub, layer your application on top, push it to a registry, and deploy. At no point does anyone hand you a list of what's actually inside that image.
That's a problem. When the next critical CVE drops, you need to know whether your running containers include the affected library -- and you need that answer in minutes, not days.
SBOMs solve this. Here's how to generate them from container images in a way that's accurate, automated, and actually useful.
Why Container SBOMs Are Different
Generating an SBOM from source code is relatively straightforward: parse the lockfile, walk the dependency tree, emit the inventory. Container images are harder for several reasons.
Multiple package managers coexist. A single container image might contain packages installed via apt-get, pip, npm, and a statically-linked Go binary -- all in one filesystem layer. Your SBOM tool needs to detect and catalog all of them.
Base images carry baggage. Your Dockerfile might only add five packages, but the base image brings hundreds. Those base-image packages are your responsibility too. If libssl in the base image has a CVE, that's your CVE in production.
Layer caching hides mutations. Docker layers are additive. A file deleted in a later layer still exists in the image archive. Some SBOM tools scan the final filesystem; others scan all layers. The difference matters for completeness.
Runtime vs. build-time dependencies. Multi-stage builds mean the final image may contain a fraction of what was present during build. Your SBOM should reflect the final image, not the builder stage.
Tools for Container SBOM Generation
Syft
Syft from Anchore is purpose-built for container image analysis. It understands OCI image formats natively and can scan images from registries without pulling them to disk.
# Scan an image from Docker Hub
syft alpine:3.17 -o cyclonedx-json > alpine-sbom.json
# Scan a local image
syft docker:myapp:latest -o spdx-json > myapp-sbom.spdx.json
# Scan an image from a private registry
syft registry:ghcr.io/myorg/myapp:v1.2.3 -o cyclonedx-json > sbom.json
Syft detects packages from:
- APK (Alpine)
- DPKG (Debian/Ubuntu)
- RPM (RHEL/CentOS/Fedora)
- Python (pip, poetry, conda)
- Node.js (npm, yarn)
- Go (modules, binaries)
- Java (JAR, WAR, EAR)
- Ruby (gems)
- Rust (cargo)
- And more
Trivy
Trivy combines SBOM generation with vulnerability scanning, which makes it particularly useful for container workflows where you want both in one pass.
# Generate SBOM and scan for vulnerabilities in one command
trivy image --format cyclonedx --output sbom.json nginx:latest
# Scan a remote image without pulling
trivy image --remote registry:ghcr.io/myorg/myapp:v1 --format spdx-json
Docker Scout (SBOM)
Docker Desktop now includes Docker Scout, which generates SBOMs natively:
# Generate SBOM using Docker CLI
docker scout sbom nginx:latest --format cyclonedx > sbom.json
This is convenient if you're already in the Docker ecosystem, though the tool is newer and less configurable than Syft or Trivy.
Tern
Tern takes a different approach by analyzing the Dockerfile and image layers to reconstruct the bill of materials:
# Analyze a Dockerfile
tern report -d Dockerfile -o sbom.json -f cyclonedxjson
Tern is particularly useful when you want to understand which Dockerfile instruction introduced each package.
CI/CD Integration Patterns
GitHub Actions
name: Container SBOM
on:
push:
branches: [main]
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: myapp:${{ github.sha }}
format: cyclonedx-json
output-file: sbom.json
- name: Upload SBOM
uses: actions/upload-artifact@v3
with:
name: container-sbom
path: sbom.json
GitLab CI
generate-sbom:
stage: security
image: anchore/syft:latest
script:
- syft ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA} -o cyclonedx-json > sbom.json
artifacts:
paths:
- sbom.json
expire_in: 1 year
Attaching SBOMs to OCI Images
The OCI specification supports attaching artifacts -- including SBOMs -- directly to container images in registries. Tools like Cosign and ORAS make this practical:
# Sign and attach SBOM with Cosign
cosign attach sbom --sbom sbom.json myregistry.io/myapp:v1.0
# Attach with ORAS
oras attach myregistry.io/myapp:v1.0 sbom.json:application/vnd.cyclonedx+json
This means consumers can pull the SBOM directly from the registry alongside the image -- no separate distribution channel needed.
Handling Multi-Stage Builds
Multi-stage Docker builds are the norm for compiled languages. The builder stage might include GCC, Make, and a full SDK. The runtime stage has only the compiled binary and minimal OS packages.
Your SBOM should reflect the runtime stage, since that's what runs in production:
# Builder stage -- not in the final SBOM
FROM golang:1.20 AS builder
COPY . .
RUN go build -o /app
# Runtime stage -- this is what your SBOM covers
FROM alpine:3.17
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
When you scan the final image, Syft and Trivy will only see the alpine:3.17 base plus your binary. They won't include the Go toolchain from the builder stage.
However, if your Go binary embeds dependencies (which Go modules do), tools like Syft can detect them by reading the Go binary's embedded module information. This is important -- your SBOM captures both OS-level packages and application-level dependencies.
Dealing with Distroless and Scratch Images
Distroless images (from Google) and FROM scratch images strip away the OS entirely. There's no package manager, no shell, no apt database to query.
For these images, SBOM tools rely on:
- Binary analysis -- reading dependency information embedded in compiled binaries
- Layer analysis -- inspecting what was copied into the image
- Lockfile detection -- finding
package-lock.json,go.sum, or similar files baked into the image
Syft handles distroless images well for Go and Java. For other languages, you may need to generate the SBOM from source and carry it forward as an OCI artifact.
Quality Checks for Container SBOMs
Not every SBOM tool catches everything. After generating a container SBOM, validate it:
-
Check component count. If your image has 200 packages installed via
aptbut your SBOM lists 50 components, something was missed. -
Verify OS packages. Run
dpkg -lorapk listinside the container and compare against the SBOM. -
Check for application dependencies. If your image runs a Node.js app, the SBOM should include npm packages, not just OS packages.
-
Validate identifiers. Each component should have a Package URL (purl) or CPE that maps to vulnerability databases.
# Quick sanity check: count components in a CycloneDX SBOM
cat sbom.json | jq '.components | length'
# List all component purls
cat sbom.json | jq -r '.components[].purl // empty'
Scanning at Scale
If you're running hundreds of container images, manual SBOM generation doesn't scale. You need:
- Registry-level scanning -- tools that watch your container registry and generate SBOMs for every new image automatically
- Continuous re-evaluation -- SBOMs that are matched against new CVEs as they're published, not just at generation time
- Centralized inventory -- a single place to query "which images contain library X?"
This is where dedicated platforms earn their keep.
How Safeguard.sh Helps
Safeguard integrates directly with your container registries and CI/CD pipelines to generate and ingest SBOMs for every image you build or deploy. Each SBOM is continuously evaluated against live vulnerability data, so when a new CVE hits a base image package, you know immediately which deployments are affected. The platform handles CycloneDX and SPDX, supports OCI artifact attachment, and gives you a searchable inventory across all your container images -- no scripting required.