Generating an SBOM for a vendored GDAL wheel
This page answers one question: standard bill-of-materials tools read Python metadata and therefore see none of the twelve native libraries inside your wheel, so how do you produce an inventory from the artifact itself and ship it where users will find it? It sits inside the Reproducible Builds and Supply-Chain Attestation section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the extraction, the format decision and the placement.
Context & Root Cause
A vendored spatial wheel typically contains GDAL, PROJ, GEOS, SQLite, libtiff, libgeotiff, libwebp, zlib, libdeflate, libcurl and one or two more — none of which appears in any Python metadata, because none of them is a Python distribution. A conventional inventory therefore reports two entries for a package whose actual attack and licence surface is a dozen C libraries.
That gap matters for three audiences with different questions. A user hit by an advisory wants to know whether the affected library is inside and at which version. A downstream packager wants to know what the wheel bundles so they can decide whether to use it. And a licence review wants to know which licences apply. All three questions are about the native payload, and all three are answered by an inventory derived from the artifact rather than from the manifest.
Solution / Fix
This targets a repaired wheel, auditwheel 6.x and ordinary shell tooling; nothing here needs a specific SBOM product.
1. Enumerate what is actually in the wheel
unzip -o dist/*.whl -d /tmp/w >/dev/null
find /tmp/w -name '*.so*' -o -name '*.dylib' -o -name '*.dll' | sort
2. Recover a version for each library
for so in /tmp/w/*.libs/*; do
name=$(basename "$so" | sed -E 's/-[0-9a-f]{6,}//; s/\.(so|dylib).*//; s/\.dll$//')
# most libraries record a version banner in their strings
ver=$(strings "$so" | grep -oiE "${name#lib}[ /-]?[0-9]+\.[0-9]+(\.[0-9]+)?" | head -1 \
| grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?')
sha=$(sha256sum "$so" | cut -c1-16)
printf '%s\t%s\t%s\n' "$name" "${ver:-unknown}" "$sha"
done | sort -u | tee dist/inventory.tsv
3. Fill the gaps from the build’s own pins
# strings is a heuristic; the pinned versions are authoritative where they exist
python - <<'PY' > dist/sbom.json
import json, pathlib, re
pins = dict(re.findall(r'(\w+)=([\d.]+)', pathlib.Path("ci/native-versions.lock").read_text()))
rows = [l.split("\t") for l in pathlib.Path("dist/inventory.tsv").read_text().splitlines()]
out = []
for name, ver, sha in rows:
key = name.removeprefix("lib").split("_")[0]
out.append({"name": name, "version": pins.get(key, ver), "sha256_prefix": sha,
"source": "pinned" if key in pins else "detected"})
json.dump({"artifact": "geo-core", "components": out}, open("/dev/stdout", "w"), indent=1)
PY
4. Ship it in three places
cp dist/sbom.json release-artifacts/ # beside the wheel
# and inside the package, so it survives installation:
# geo_core/_sbom.json, loaded by geo_core.provenance()
# and summarised in the release notes as four lines of versions
Verification
# 1. Every bundled binary appears in the inventory
diff <(find /tmp/w -name '*.so*' -printf '%f\n' | sed -E 's/-[0-9a-f]{6,}//' | sort -u) \
<(cut -f1 dist/inventory.tsv | sort -u)
# expected: no differences
# 2. Every version is known
awk -F'\t' '$2 == "unknown"' dist/inventory.tsv
# expected: empty — anything listed here needs a pin or a better detection rule
# 3. The installed package reports the same thing
pip install -q dist/*.whl
python -c "import geo_core, json; print(json.dumps(geo_core.provenance(), indent=1))"
The first check is what keeps the inventory honest over releases. A codec pulled in by a GDAL configure change appears in the wheel without appearing in any list you maintain by hand, and the diff catches it on the next build rather than during an incident.
What Goes in an Entry
An inventory is only as useful as the fields it carries, and for this domain four matter more than the rest.
The last field is unusual and worth including. An inventory that presents a heuristically-detected version with the same confidence as a pinned one invites a reader to act on a guess. Marking the difference costs one key and makes the document honest — and it creates useful pressure to pin the libraries whose versions are only ever detected.
Licence data cannot be extracted from a binary and has to come from the build. Recording it alongside the version pin, in the same file, keeps the two together and makes the inventory generation a join rather than a research exercise.
Choosing a Format
Format matters less than accuracy, and the choice follows from who consumes it.
That last line is the substantive point. Several tools produce a standards-compliant document from a Python package and, because they read metadata, produce a compliant document that omits every native library. Compliance and accuracy are independent properties here, and only one of them helps a user answer a question.
Pitfalls & Alternatives
Relying on strings alone for versions. It works for libraries that record a banner and fails silently for those that do not. Use it to discover what is present and the build pins to state what version it is.
Generating the inventory from the build environment. The environment contains things the wheel does not, and the wheel may contain things installed transitively. Generate from the unpacked artifact.
Publishing it only as a release asset. A user diagnosing behaviour on their own machine cannot reach a release page from inside a container. Ship a copy inside the package as well.
Letting it drift out of the release checklist. It is generated from the wheel, so it can be produced by the same job that validates the wheel — which is the only placement that survives a busy release.
Frequently Asked Questions
Does this replace a Python-level dependency scan?
No, it complements one. The Python scan covers the declared dependency graph, which is real and which conventional tooling handles well. This covers the native payload, which that tooling cannot see. Both belong in a release.
How do I get licence information for the bundled libraries?
From the build, recorded next to the version pin. The libraries’ own licence files can also be copied into the wheel, which some downstream consumers expect and which makes the claim verifiable rather than asserted.
Should the inventory include the Python dependencies too?
Including them makes it a complete picture and costs nothing, since that data is already available. What matters is that the native entries are present; whether the Python ones sit beside them is a formatting choice.
What if a bundled library has no discoverable version?
Pin it. An entry whose version is unknown is a gap in exactly the place the document exists to cover, and the fix is upstream in your own build rather than in the extraction script.
Does an attestation make an inventory unnecessary?
They answer different questions. The attestation says this file came from that workflow; the inventory says what is inside it. A user asking whether they are affected by a codec advisory needs the second and gains nothing from the first.
How large does this get for a spatial wheel?
A dozen or so entries — small enough to read, which is part of why the plain table is worth generating alongside any structured format. If it runs to hundreds of entries, something is enumerating symbols rather than libraries.
How do I handle a library that is statically linked rather than bundled?
It does not appear as a file, so the artifact scan cannot see it — and it is still in the wheel. Statically absorbed libraries have to come from the build pins, and marking their entries as such keeps the document honest about how each fact was established.
Should the inventory list the wheel’s own hash?
It is useful, because it ties the inventory to a specific artifact rather than to a version. A user comparing what they installed against what you published can then check one line rather than trusting that the file they have is the file you described.
Does this need regenerating for every platform wheel?
Yes, because the contents can differ: a Windows wheel bundles DLLs a Linux one does not, and a macOS wheel may carry different codecs. Generating per artifact and publishing them together is the accurate approach; a single inventory for a multi-platform release is an approximation that will eventually be wrong.
How do I keep the extraction script from rotting?
Run it in the same job that builds the wheel, and fail the build when a library appears with no version. That converts silent staleness into a red build at the moment a new dependency arrives, which is the only point at which the fix is cheap.
Related
- Reproducible builds and supply-chain attestation — the parent guide and the three outputs a release should carry.
- Auditing CVEs in vendored GDAL and PROJ — the question this inventory exists to answer quickly.
- Pinning GDAL and PROJ versions across a wheel set — where the authoritative version numbers come from.