Skip to content

Commit cfe6310

Browse files
committed
feat: verify PBS embedded native libs against the manifest (drift => RED)
A PBS release can bump its statically linked native libraries while the CPython versions stay unchanged (maintainer-reported gap: python pin alone would not notice). The updater now downloads the x86_64 install tarball (sha-verified against SHA256SUMS), dissects libpython3*.so and compares the embedded openssl/zlib/expat/ncurses/bzip2/sqlite/xz version markers against the release manifest; a mismatch aborts the update. trivy cannot see these libraries in any mode, so the dissection is the only drift check possible. The contract test gained a hermetic tarball fixture (PBS_TARBALL_FILE) and a drift phase: a tarball whose markers no longer match the manifest must fail the updater without mutating the workspace. get_python_archs now returns an empty result for a minor missing from config.bzl instead of aborting under errexit+pipefail (the new-minor fallback relies on it).
1 parent cba59e3 commit cfe6310

5 files changed

Lines changed: 245 additions & 3 deletions

File tree

knife.d/update_python_archives.sh

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ function get_python_minors() {
4848
# prints archs for a minor from the build matrix, one per line
4949
function get_python_archs() {
5050
local minor="$1"
51+
# a missing minor must yield an empty result (the caller falls back to the
52+
# previous minor's archs for a newly detected one), NOT kill the updater:
53+
# errexit+pipefail would otherwise abort on the failed grep.
5154
grep "\"${minor}\": \[" python/config.bzl \
52-
| grep -oE '"[a-z0-9]+"' | tr -d '"'
55+
| grep -oE '"[a-z0-9]+"' | tr -d '"' || true
5356
}
5457

5558
function triple_for_arch() {
@@ -241,6 +244,62 @@ function generate_python_archives() {
241244
grep -q "$latest_release" "$sbom_tmp" || { echo "PBS SBOM does not mention ${latest_release}" >&2; rm -f "$sbom_tmp" "$downloads_tmp"; exit 1; }
242245
[ -n "${PBS_DOWNLOADS_FILE:-}" ] || rm -f "$downloads_tmp"
243246

247+
# PBS embedded native libraries: dissect libpython3*.so from the x86_64 install
248+
# tarball and verify the statically linked C libraries against the manifest.
249+
# A release can bump the embedded libs while the CPython versions stay the same
250+
# (maintainer-reported gap); this turns that drift into a hard error. The tarball
251+
# is sha-verified against SHA256SUMS. Hermetic tests inject a fake tarball via
252+
# PBS_TARBALL_FILE.
253+
local tarball tarball_tmp so_dir so_path
254+
if [ -n "${PBS_TARBALL_FILE:-}" ]; then
255+
tarball="$PBS_TARBALL_FILE"
256+
else
257+
tarball_tmp=$(mktemp)
258+
local fname tarball_sha
259+
fname=$(printf '%s\n' "$sha256sums" | awk '/x86_64-unknown-linux-gnu-install_only\.tar\.gz$/ {print $2; exit}')
260+
tarball_sha=$(printf '%s\n' "$sha256sums" | awk -v f="$fname" '$2 == f {print $1; exit}')
261+
if [ -z "$fname" ] || [ -z "$tarball_sha" ]; then
262+
echo "no x86_64 install tarball in SHA256SUMS" >&2
263+
rm -f "$tarball_tmp"
264+
exit 1
265+
fi
266+
if ! curl -sSL "https://github.com/astral-sh/python-build-standalone/releases/download/${latest_release}/${fname}" -o "$tarball_tmp"; then
267+
echo "cannot download ${fname}" >&2
268+
rm -f "$tarball_tmp"
269+
exit 1
270+
fi
271+
local got_sha
272+
got_sha=$(python3 -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$tarball_tmp")
273+
if [ "$got_sha" != "$tarball_sha" ]; then
274+
echo "sha256 mismatch for ${fname}: expected ${tarball_sha}, got ${got_sha}" >&2
275+
rm -f "$tarball_tmp"
276+
exit 1
277+
fi
278+
tarball="$tarball_tmp"
279+
fi
280+
so_dir=$(mktemp -d)
281+
if ! python3 - "$tarball" "$so_dir" <<'PYEOF'; then
282+
import sys, tarfile
283+
tar = tarfile.open(sys.argv[1])
284+
for member in tar.getmembers():
285+
if "/lib/libpython3." in member.name and member.name.endswith(".so") and member.isfile():
286+
tar.extract(member, sys.argv[2])
287+
sys.exit(0)
288+
sys.exit("no libpython3*.so in tarball")
289+
PYEOF
290+
rm -rf "$so_dir" "$tarball_tmp"
291+
exit 1
292+
fi
293+
so_path=$(find "$so_dir" -name 'libpython3.*.so' | head -1)
294+
# stdout is the updater's machine contract (the release tag); diagnostics to stderr.
295+
if ! python3 python/pbs_embedded_versions.py "$so_path" "$downloads_file" >&2; then
296+
echo "PBS embedded native libraries drift detected" >&2
297+
rm -rf "$so_dir" "$tarball_tmp"
298+
exit 1
299+
fi
300+
rm -rf "$so_dir"
301+
[ -n "${PBS_TARBALL_FILE:-}" ] || rm -f "$tarball_tmp"
302+
244303
printf '%s\n' "${changes[@]}" >&2
245304

246305
local start end section tmp

python/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ sh_test(
2525
data = [
2626
":config.bzl",
2727
":gen_pbs_sbom.py",
28+
":pbs_embedded_versions.py",
2829
":update_python_archives_test.sh",
2930
"testdata/python3.13.yaml",
3031
"testdata/python3.14.yaml",

python/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,10 @@ source pins (`pkg:generic`) that SBOM scanners skip by design (no advisory feed
7373
for source-pinned C libraries). Check it with:
7474

7575
trivy sbom --scanners vuln,license --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 python/pbs-sbom.spdx.json
76+
77+
The updater additionally dissects the x86_64 install tarball's `libpython3*.so`
78+
(`python/pbs_embedded_versions.py`) and verifies the embedded openssl/zlib/expat/
79+
ncurses/bzip2/sqlite/xz versions against the manifest — a release that bumps the
80+
native libraries while the CPython version stays the same fails the update
81+
(`trivy image` cannot see statically embedded libraries; the dissection is the
82+
only check that can).

python/pbs_embedded_versions.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""Verify the native libraries statically linked into a PBS libpython .so.
3+
4+
python-build-standalone compiles the C libraries (openssl, sqlite, zlib, ...)
5+
into libpython3.x.so at release build time. No SBOM scanner can see them there
6+
(trivy's binary detection does not cover statically embedded libs), and a
7+
release can bump them while the CPython version stays unchanged - invisible
8+
when only the python version pin is tracked. This dissects the binary for the
9+
version markers those libraries leave behind and compares them against the
10+
release manifest (pythonbuild/downloads.py).
11+
12+
Marker patterns were empirically verified against cpython-3.13.15+20260814:
13+
strong openssl "OpenSSL 3.5.7 9 Jun 2026"
14+
zlib "deflate 1.3.2 Copyright 1995-2026 Jean-loup Gailly"
15+
expat "expat_2.8.3"
16+
ncurses "ncurses 6.5.20240427" (manifest keeps the 6.5 prefix)
17+
bzip2 "1.0.8, 13-Jul-2019"
18+
weak sqlite bare "3.53.1" literal (manifest actual_version)
19+
xz bare "5.8.3" literal
20+
absent libffi, readline, gdbm, tcl, uuid, libedit, libX11, libxcb: no
21+
version string is embedded -> reported unverifiable, never fails.
22+
23+
Usage: pbs_embedded_versions.py <libpython.so> <downloads.py>
24+
Exit code 0 = every extractable manifest library matches the binary;
25+
1 = a library's embedded version differs from the manifest (drift).
26+
"""
27+
import importlib.util
28+
import re
29+
import sys
30+
31+
# manifest key -> (regex with one capture group for the version)
32+
STRONG = [
33+
("openssl-3.5", re.compile(rb"OpenSSL (\d+\.\d+\.\d+[a-z]?)\s+\d{1,2} [A-Z][a-z]{2} \d{4}")),
34+
("openssl-1.1", re.compile(rb"OpenSSL (1\.1\.1[a-z]?)\s+\d{1,2} [A-Z][a-z]{2} \d{4}")),
35+
("zlib", re.compile(rb"deflate (\d+\.\d+\.\d+) Copyright")),
36+
("expat", re.compile(rb"expat_(\d+\.\d+\.\d+)")),
37+
("ncurses", re.compile(rb"ncurses (\d+\.\d+\.\d+(?:\.\d+)?)")),
38+
("bzip2", re.compile(rb"(\d+\.\d+\.\d+), \d{1,2}-[A-Z][a-z]{2}-\d{4}")),
39+
]
40+
# manifest keys with only a bare version literal: presence check (weak)
41+
WEAK = ("sqlite", "xz")
42+
43+
44+
def load_manifest(path):
45+
spec = importlib.util.spec_from_file_location("pbs_downloads", path)
46+
if spec is None or spec.loader is None:
47+
sys.exit("cannot load manifest: " + path)
48+
module = importlib.util.module_from_spec(spec)
49+
spec.loader.exec_module(module)
50+
return module.DOWNLOADS
51+
52+
53+
def expected_version(entry):
54+
"""Human version for the manifest entry: actual_version when present
55+
(sqlite ships its SQLITE_VERSION_NUMBER in `version`, e.g. 3530100,
56+
alongside actual_version 3.53.1.0), else `version` as-is."""
57+
v = entry.get("actual_version") or entry.get("version") or ""
58+
return re.sub(r"\.0$", "", v)
59+
60+
61+
def marker_version(blob, regex, name, entry):
62+
match = regex.search(blob)
63+
if not match:
64+
return None # marker absent (library not linked into this .so)
65+
return match.group(1).decode()
66+
67+
68+
def check_weak(blob, name, entry):
69+
expected = expected_version(entry)
70+
if not expected:
71+
return None
72+
# bare literal with non-digit boundaries: "3.53.1" must not match "3.53.10"
73+
pattern = re.compile(rb"(?<![0-9])" + re.escape(expected.encode()) + rb"(?![0-9])")
74+
return expected if pattern.search(blob) else None
75+
76+
77+
def main():
78+
so_path, manifest_path = sys.argv[1], sys.argv[2]
79+
with open(so_path, "rb") as fh:
80+
blob = fh.read()
81+
downloads = load_manifest(manifest_path)
82+
83+
failures = 0
84+
for name, regex in STRONG:
85+
if name not in downloads:
86+
continue
87+
found = marker_version(blob, regex, name, downloads[name])
88+
if found is None:
89+
print("ABSENT {:<10} (no version marker in binary)".format(name))
90+
continue
91+
expected = expected_version(downloads[name])
92+
if found == expected or (name == "ncurses" and found.startswith(expected)):
93+
print("OK {:<10} {}".format(name, found))
94+
else:
95+
print("MISMATCH {:<10} manifest={} binary={}".format(name, expected, found))
96+
failures += 1
97+
for name in WEAK:
98+
if name not in downloads:
99+
continue
100+
found = check_weak(blob, name, downloads[name])
101+
expected = expected_version(downloads[name])
102+
if found is None:
103+
print("MISMATCH {:<10} manifest={} binary=(no bare version literal)".format(name, expected))
104+
failures += 1
105+
else:
106+
print("OK {:<10} {} (weak marker)".format(name, found))
107+
108+
if failures:
109+
print("PBS embedded native libraries drift detected (see above)", file=sys.stderr)
110+
sys.exit(1)
111+
print("embedded native libraries verified against the release manifest")
112+
113+
114+
if __name__ == "__main__":
115+
main()

python/update_python_archives_contract_test.sh

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ cp MODULE.bazel "$FIX/"
2727
cp python/update_python_archives_test.sh "$FIX/python/"
2828
cp python/gen_pbs_sbom.py "$FIX/python/"
2929
cp python/testdata/python3.13.yaml python/testdata/python3.14.yaml "$FIX/python/testdata/"
30+
cp python/pbs_embedded_versions.py "$FIX/python/"
3031

3132
# fake PBS component manifest (pythonbuild/downloads.py) for the SBOM step
3233
cat > "$FIX/downloads.py" <<'EOF'
@@ -42,6 +43,12 @@ DOWNLOADS = {
4243
"licenses": ["MIT"],
4344
"library_names": ["expat"],
4445
},
46+
"sqlite": {
47+
"url": "https://example.invalid/sqlite.tar.gz",
48+
"version": "3530100",
49+
"actual_version": "3.53.1.0",
50+
"library_names": ["sqlite3"],
51+
},
4552
"zlib": {
4653
"url": "https://example.invalid/zlib.tar.gz",
4754
"version": "1.3.2",
@@ -51,6 +58,27 @@ DOWNLOADS = {
5158
}
5259
EOF
5360

61+
# fake PBS install tarball: python/lib/libpython3.13.so with the same embedded
62+
# library markers the real one carries (see python/pbs_embedded_versions.py)
63+
make_tarball() { # $1 = output path; markers must match the fixture manifest
64+
python3 - "$1" <<'PYEOF'
65+
import sys, tarfile, io
66+
blob = (
67+
b"OpenSSL 3.5.7 9 Jun 2026\n"
68+
b"deflate 1.3.2 Copyright 1995-2026 Jean-loup Gailly and Mark Adler\n"
69+
b"expat_2.8.3\n"
70+
b"ncurses 6.5.20240427\n"
71+
b"1.0.8, 13-Jul-2019\n"
72+
b"3.53.1\n5.8.3\n"
73+
)
74+
with tarfile.open(sys.argv[1], "w:gz") as tar:
75+
info = tarfile.TarInfo("python/lib/libpython3.13.so")
76+
info.size = len(blob)
77+
tar.addfile(info, io.BytesIO(blob))
78+
PYEOF
79+
}
80+
make_tarball "$FIX/tarball.tar.gz"
81+
5482
cd "$FIX"
5583
source update_python_archives.sh
5684

@@ -75,9 +103,17 @@ make_sha256sums() { # $1=release $2=patch313 $3=patch314 $4=patch315 ("" = no 3.
75103
}
76104

77105
run_updater() { # prints stdout; fails the test on a non-zero exit
106+
# bash -c: the updater aborts with exit 1 on fatal errors (knife contract),
107+
# which must not kill the test script.
108+
PBS_RELEASE_FILE="$FIX/release.json" PBS_SHA256SUMS_FILE="$FIX/SHA256SUMS" \
109+
PBS_DOWNLOADS_FILE="$FIX/downloads.py" PBS_TARBALL_FILE="$FIX/tarball.tar.gz" \
110+
bash -c 'source update_python_archives.sh; generate_python_archives' 2>"$FIX/updater.err"
111+
}
112+
113+
run_updater_expect_fail() { # non-zero exit is the expectation (drift => RED)
78114
PBS_RELEASE_FILE="$FIX/release.json" PBS_SHA256SUMS_FILE="$FIX/SHA256SUMS" \
79-
PBS_DOWNLOADS_FILE="$FIX/downloads.py" \
80-
generate_python_archives 2>"$FIX/updater.err"
115+
PBS_DOWNLOADS_FILE="$FIX/downloads.py" PBS_TARBALL_FILE="$FIX/drift.tar.gz" \
116+
bash -c 'source update_python_archives.sh; generate_python_archives' 2>"$FIX/updater.err" && return 1 || return 0
81117
}
82118

83119
# --- phase A: tag-only bump (new release, same patches) -----------------------
@@ -94,6 +130,30 @@ grep -q 'Python 3.13.15' python/testdata/python3.13.yaml || { echo "phase A: tes
94130
grep -q '20990101' python/pbs-sbom.spdx.json || { echo "phase A: SBOM not regenerated for the new release"; exit 1; }
95131
grep -q '"expat"' python/pbs-sbom.spdx.json || { echo "phase A: SBOM missing bundled component"; exit 1; }
96132

133+
# --- phase DRIFT: embedded library bumped without a manifest change ----------
134+
# the maintainer-reported gap made a hard error: a release whose binary embeds
135+
# e.g. zlib 1.3.3 while the manifest still pins 1.3.2 must fail the updater.
136+
python3 - "$FIX/drift.tar.gz" <<'PYEOF'
137+
import sys, tarfile, io
138+
blob = (
139+
b"OpenSSL 3.5.7 9 Jun 2026\n"
140+
b"deflate 1.3.3 Copyright 1995-2026 Jean-loup Gailly and Mark Adler\n"
141+
b"expat_2.8.3\n"
142+
b"ncurses 6.5.20240427\n"
143+
b"1.0.8, 13-Jul-2019\n"
144+
b"3.53.1\n5.8.3\n"
145+
)
146+
with tarfile.open(sys.argv[1], "w:gz") as tar:
147+
info = tarfile.TarInfo("python/lib/libpython3.13.so")
148+
info.size = len(blob)
149+
tar.addfile(info, io.BytesIO(blob))
150+
PYEOF
151+
echo '{"tag": "20990102"}' > release.json
152+
make_sha256sums 20990102 3.13.15 3.14.7
153+
run_updater_expect_fail || { echo "phase DRIFT: expected the updater to fail"; cat "$FIX/updater.err"; exit 1; }
154+
grep -qi 'drift' "$FIX/updater.err" || { echo "phase DRIFT: missing drift error message"; cat "$FIX/updater.err"; exit 1; }
155+
! grep -q '20990102' private/extensions/python.bzl || { echo "phase DRIFT: workspace must be untouched after a RED"; exit 1; }
156+
97157
# --- phase B: patch bump (new release, new patches) ---------------------------
98158
snap_b=$(get_python_versions)
99159
echo '{"tag": "20990102"}' > release.json

0 commit comments

Comments
 (0)