Export an SBOM or Scan Result From a Real Build
Export an SPDX SBOM from a real build, list its packages and scan it with Grype, without a Docker account. After this, you can hand over what is in an image and what was known about it on a given day.
An SBOM (software bill of materials) is a list of every package inside an image, with versions. A scan result is that list compared with known vulnerabilities on a given day. Both are things a customer, an auditor or your own security team may ask for, and both are easy to produce from a real build. This page exports an SBOM from a build, lists it, and scans it, without a Docker account. Run on Docker Desktop 4.85.0 (Engine 29.6.2) with BuildKit, Docker Scout CLI 1.23.1 (only for the package list) and Grype 0.120.1 in a container (for the scan).
Build with an SBOM
requests==2.32.5
FROM python:3.14-alpine
COPY requirements.txt .
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements.txt
CMD ["python", "-c", "import requests; print(requests.__version__)"]
--sbom=true tells BuildKit to scan the result and attach an SBOM in SPDX format. With --output type=tar, the image and its SBOM are written to one file instead of your image list.
Set-Location sbom-demo
docker buildx build -q --sbom=true --output type=tar,dest=image.tar . | Out-Null
tar -tf image.tar | Select-String "^sbom"
tar -xf image.tar sbom.spdx.json
(Get-Content sbom.spdx.json -Raw | ConvertFrom-Json).PSObject.Properties.Name
sbom.spdx.json
_type
predicateType
subject
predicate
The tar holds the whole image filesystem plus sbom.spdx.json at the top. That file is not plain SPDX yet. BuildKit wraps it as an in-toto attestation: predicate holds the SPDX document, and subject says which image it belongs to. Scanners expect the plain document, so I unwrap it into its own file, written as UTF-8 without a byte order mark:
$spdx = (Get-Content sbom.spdx.json -Raw | ConvertFrom-Json).predicate
[IO.File]::WriteAllText("$PWD\sbom.json", ($spdx | ConvertTo-Json -Depth 100 -Compress))
"SBOM size: {0:N0} KB" -f ((Get-Item sbom.json).Length / 1KB)
SBOM size: 620 KB
Keep both files with the build in your CI artifacts, together with the image digest, so you can always say what was in a given release.
On Windows, --output type=local,dest=folder failed for me with "A required privilege is not held by the client", because the image contains Linux symbolic links that Windows will not create without extra rights. The tar output avoids that.
What is in it
The file is JSON. PowerShell can count and list packages directly:
$sbom = Get-Content sbom.json -Raw | ConvertFrom-Json
"packages listed: " + $sbom.packages.Count
$sbom.packages | Where-Object { $_.name -in "requests", "urllib3", "python", "musl" } | Sort-Object name | ForEach-Object { "{0} {1}" -f $_.name, $_.versionInfo } | Select-Object -Unique
packages listed: 63
musl 1.2.6-r2
python 3.14.8
requests 2.34.2
requests 2.32.5
urllib3 2.8.0
urllib3 2.7.0
Both the Alpine system packages and the Python packages from pip install are in the list. requests and urllib3 each appear twice. The SBOM's own notes on each entry explain why: requests 2.32.5 and urllib3 2.8.0 are what pip install put in site-packages, while requests 2.34.2 and urllib3 2.7.0 come from the SBOM file that pip ships for the copies it bundles inside itself (pip/_vendor/bom.cdx.json). That is the point of building the SBOM from the real image instead of from requirements.txt: it also catches what the base image brought along.
If you prefer a table, docker scout sbom --format list IMAGE works without signing in for an image in your local list. Its vulnerability commands (docker scout cves, quickview) asked me to sign in with a Docker ID, so I used Grype for the scan below.
Scan the SBOM
Grype can read the SBOM file directly, so the scan does not even need the image:
docker run --rm -v "${PWD}:/work:ro" anchore/grype:v0.120.1 sbom:/work/sbom.json --only-fixed -o table -q 2>$null
"grype exit code: $LASTEXITCODE"
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
setuptools 70.3.0 78.1.1 python GHSA-5rjg-fvgr-3xxf High 1.6% (75th) 1.2
zlib 1.3.2-r0 1.3.2-r1 apk CVE-2026-85091 High 0.4% (27th) 0.3
urllib3 2.7.0 2.8.0 python GHSA-vxq7-64xx-v4gw High 0.3% (25th) 0.3
setuptools 70.3.0 83.0.0 python GHSA-h35f-9h28-mq5c Medium 0.4% (33rd) 0.2
python 3.14.8 3.15.0a6 UnknownPackage CVE-2025-15367 Medium 0.4% (30th) 0.2
urllib3 2.7.0 2.8.0 python GHSA-gh4c-6fx4-qh6g Medium 0.3% (20th) 0.2
urllib3 2.7.0 2.8.0 python GHSA-8988-9cw3-xx77 High 0.2% (10th) 0.2
python 3.14.8 3.15.0rc3 UnknownPackage CVE-2026-12345 Medium 0.2% (7th) < 0.1
requests 2.32.5 2.33.0 python GHSA-gc5v-m9x4-r6x2 Medium 0.2% (7th) < 0.1
msgpack 1.1.2 1.2.1 python GHSA-6v7p-g79w-8964 High N/A N/A
grype exit code: 0
The urllib3 2.7.0 findings are in pip's bundled copy, not in the version the app uses. That is still worth knowing: pip is in the image. Your result will not match mine, and it should not. The packages are fixed, but the vulnerability database changes every day. A scan result is only meaningful with its date and the scanner version. To keep one as a file:
docker run --rm -v "${PWD}:/work" anchore/grype:v0.120.1 sbom:/work/sbom.json -o json --file /work/scan-2026-10-21.json -q 2>$null
"scan saved: " + (Test-Path scan-2026-10-21.json)
scan saved: True
Make the build fail on serious findings
Grype can return a non-zero exit code when it finds something at or above a severity, which is what a CI step needs:
docker run --rm -v "${PWD}:/work:ro" anchore/grype:v0.120.1 sbom:/work/sbom.json --only-fixed --fail-on critical -q 2>$null | Out-Null
"exit code with --fail-on critical: $LASTEXITCODE"
exit code with --fail-on critical: 0
0 means nothing at that level with a fix available today. Choose the level you are ready to act on. A gate that fails every build gets switched off.
If it does not work
--sbomis ignored or fails: check that you build with BuildKit (docker buildx build), and that your builder supports attestations. Docker Desktop's default builder did here.- Grype is slow on the first run: it downloads its vulnerability database every time the container starts fresh. In CI, cache that folder.
- An SBOM with almost nothing in it: a
scratchimage with one static binary has very few packages to list. Generate the SBOM for the build stage or from the source as well.
Related course: Docker Advanced.
About
Export an SPDX SBOM from a real build, list its packages and scan it with Grype, without a Docker account. After this, you can hand over what is in an image and what was known about it on a given day.
- Responsible
- Mary Q
- Last Update
- 08/03/2026
- Reading Time
- 6 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.