trustbom
An SBOM for the part of your container nobody declared: its TLS trust store.
$ trustbom inspect alpine:latest
alpine:latest
path /etc/ssl/certs/ca-certificates.crt
sha256 b8d837841b88bfaa1a0fa827cbca8e2576418dd47c9fc4bb7f1f9d89c83111b9
roots 119
shadowed, present but never read by Go:
/etc/ssl/cert.pem
by organization
6 5.0% D-Trust GmbH
6 5.0% SSL Corporation
5 4.2% DigiCert Inc
4 3.4% Amazon
...
by country
35 29.4% US
13 10.9% DE
12 10.1% CN
...
The problem
Ask most teams which certificate authorities their service trusts and you get a
blank look, because nobody decided. A typical Go Dockerfile ends like this:
FROM golang:1.26-alpine AS build
RUN apk add -U --no-cache ca-certificates
...
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
The service now trusts whatever Alpine trusted on the day that layer was cached.
That set changes when the build image is bumped. Nothing announces it, nothing
fails, and the diff shows one unchanged line.
Two ways it goes wrong, both seen in the wild:
- The bundle silently changes. A Debian
ca-certificates bump changed which
roots were trusted and broke TLS for people using distroless images
(distroless#545).
DST Root CA X3 expiring broke Alpine-based images broadly
(docker-alpine#206,
still open).
- The wrong file gets copied. On a Red Hat base,
tls-ca-bundle.pem sits in
a directory with ca-bundle.trust.crt, email-ca-bundle.pem and
objsign-ca-bundle.pem. Copy the first by mistake and the image loads zero
roots and fails every request with x509: certificate signed by unknown authority. Copy email-ca-bundle.pem and it is worse, because it does not
look broken: measured, that swaps 75 TLS roots for 32 S/MIME ones, leaves 71 in
common, and the common ones are enough that the obvious endpoints still work.
Go does not honour trust bits, so nothing objects. A count does not catch it
(146 becomes 103, both plausible) and a smoke test does not catch it. Only the
set of fingerprints does.
- A file gets added. Go reads the first file from its list and then appends
every file in
/etc/ssl/certs and /etc/pki/tls/certs. One extra .pem in
either directory is a root the process trusts while the named bundle is byte
for byte unchanged. trustbom models the whole load, so it reports that file
by name.
A container's trust store is the one dependency with no manifest, no lock file
and no review. trustbom gives it all three.
Install
go install github.com/cniackz/trustbom@latest
Or build from source: go build -o trustbom .
Use
Look at what an image trusts
trustbom inspect ghcr.io/me/api:v1.4.0
trustbom inspect registry.access.redhat.com/ubi9/ubi-minimal --platform linux/s390x
trustbom inspect alpine:latest --json | jq '.bundle.roots[].subject'
The image is never run. trustbom reads its layers straight from the registry,
so this works on a FROM scratch image with no shell, on an image built for an
architecture the checking machine cannot execute, and inside any CI job that can
reach the registry.
Pin it, then hold builds to it
trustbom lock ghcr.io/me/api:v1.4.0 # writes trustbom.lock
git add trustbom.lock # the trust store is now in review
trustbom verify ghcr.io/me/api:v1.5.0 # exit 2 if it changed
trustbom.lock pins the SHA-256 fingerprint of every root, not a count. A count
is a smoke alarm: it moves when something is wrong, and it stays perfectly still
when one authority is swapped for another, which is the change most worth
seeing.
When a build differs you get the diff, not a number:
trust store differs from trustbom.lock
the file Go reads moved: /etc/ssl/certs/ca-certificates.crt -> /etc/pki/tls/certs/ca-bundle.crt
- CN=OISTE Server Root ECC G1,O=OISTE Foundation,C=CH
eec997c0c30f216f7e3b8b307d2bae42412d753fc8219dafd1520b2572850f49
+ CN=AffirmTrust Commercial,O=AffirmTrust,C=US
0376ab1d54c5f9803ce4b2e201a0ee7eef7b57b636e8a93c9b8d4860c96f5fa7
If this change is intended, re-run trustbom lock and commit the result.
Ask whether the browsers still trust those roots
inspect tells you which authorities are in the image. audit asks the
Common CA Database, the register Mozilla, Chrome, Microsoft
and Apple keep together, whether each one is still trusted for TLS:
$ trustbom audit alpine:latest
alpine:latest
checked against CCADB, 172 roots included by Mozilla as of 2026-08-22
117 trusted for websites
1 listed, but not for websites
1 not in Mozilla's included set
listed without the websites trust bit. This is where a root lands
after a TLS distrust, so a bundle still carrying one is trusting
something the browsers stopped trusting for TLS:
CN=Atos TrustedRoot 2011,O=Atos,C=DE
trust bits: Email
The middle category is the interesting one. A root that loses a TLS distrust is
not deleted from CCADB, it stays listed with its Websites trust bit removed, and
in the bundle file it still looks like an ordinary CERTIFICATE block. Nothing
short of this lookup can tell the difference.
--require-websites turns it into a gate that exits 2.
This is deliberately not part of lock or verify. A fingerprint is a fact
about an image and has to mean the same thing offline and a year from now.
Whether CCADB currently lists it is a fact about today, and it changes without
the image changing. Folding the two together would make a build fail for a
reason that has nothing to do with the build.
In CI
- name: The trust store is what we said it is
run: |
go install github.com/cniackz/trustbom@latest
trustbom verify ${{ env.IMAGE }} --platform linux/amd64
Exit codes are meant to be acted on separately:
| Code |
Meaning |
| 0 |
as expected |
| 1 |
usage error, or the registry could not be reached |
| 2 |
the trust store differs from the lock, or the image has none |
A registry outage should not read as a security finding, which is why 1 and 2
are not the same.
What it checks
- Which file Go actually opens.
crypto/x509 consults six paths in a fixed
order and stops at the first that exists, so a file at the first path shadows
a bundle at the fifth. trustbom reports the winner and names the shadowed
ones, which is how an image ends up carrying a current bundle and using an
older one.
- Symlinks. Red Hat images link
/etc/pki/tls/certs/ca-bundle.crt to the
extracted bundle. A tool that reads the named path without following the link
finds an empty file and reports a broken trust store that is fine.
- Blocks a TLS client cannot use.
BEGIN TRUSTED CERTIFICATE and malformed
DER are counted, and a bundle with no usable roots is an error rather than a
zero.
- Expiry. Roots already past
notAfter, and roots expiring within a year.
An expired root is dead weight rather than a hole, but it is a reliable sign
nobody has looked at this file in a long time.
- Who you trust. Roots grouped by issuing organization and by country. Most
images trust something over fifty organizations across thirty countries. Any
one of them can mint a certificate your service will accept.
- Whether the browsers still agree, via
audit and CCADB: which roots are
trusted for websites, which are listed for something else, and which are not
listed at all.
What it does not do
It does not dial anything. Knowing an image carries the roots you expect is not
the same as knowing TLS works, and the honest way to learn the second is to make
a real connection from inside the image to the endpoints the service actually
uses. docker-library/official-images has done exactly that for years to gate
the openjdk and python official images. trustbom covers the half that can
be answered without running anything; run the other half too.
It reads Go's search order. A Python, Java or Rust service resolves its trust
store differently, and the file trustbom names is the one Go would open.
License
MIT.