|
| 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() |
0 commit comments