yams/scripts/ci/check_install_surface.py
trvon 454c95245b
ci: ignore ld64's radr://5614542 marker when checking Mach-O strip state
On a macOS Release build staged and split with split-debug-symbols.sh,
check_install_surface reported every binary as shipping a symbol table.
The only stab left after strip -S -x is the 'OPT radr://5614542' entry
ld64 always writes; it carries no debug information. Unstripped binaries
still trip the check (135,012 stabs in an unsplit yams-cli).

Verified on macOS arm64 (ONNX + Glint build): RED with 8 false
'stripped' violations before, GREEN after.

Signed-off-by: trvon <me@trevon.dev>
2026-10-01 19:44:42 -06:00

601 lines
24 KiB
Python
Executable file

#!/usr/bin/env python3
"""Check the staged YAMS install surface against a checked-in policy.
The runtime install is a contract: every installed file, exported symbol and
missing hardening flag is something a package ships. This script stages a
`meson install --destdir` for a build directory (or reads an already staged
tree), then asserts it against scripts/ci/install_surface_policy.json:
* paths: every installed path matches an allowed pattern, no forbidden
pattern (headers, static archives, pkg-config, CMake files) and
every required path is present
* sizes: per-binary size budgets
* exports: per-object exported-symbol budgets and forbidden third-party
exports (sqlite3_, SSL_, curl_, boost::, spdlog::, ...)
* rpath: only $ORIGIN / @loader_path / @executable_path relative entries
* hardening: ELF PIE, BIND_NOW, GNU_RELRO, non-executable stack; Mach-O PIE
* stripped: no debug sections or symbol tables in shipped binaries
Usage:
scripts/ci/check_install_surface.py --build-dir build/release
scripts/ci/check_install_surface.py --stage /path/to/destdir --prefix /usr
With --build-dir the script stages into a temporary directory, runs the debug
split (scripts/split-debug-symbols.sh) exactly like the release workflow and
then checks. Pass --no-split to check the raw `meson install` output.
Only the Python standard library and binutils/cctools are required: readelf
and nm on Linux (llvm-readelf/llvm-nm are accepted), otool and nm on macOS.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_POLICY = REPO_ROOT / "scripts" / "ci" / "install_surface_policy.json"
SPLIT_SCRIPT = REPO_ROOT / "scripts" / "split-debug-symbols.sh"
ELF_MAGIC = b"\x7fELF"
MACHO_MAGICS = {
b"\xfe\xed\xfa\xce",
b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xcf",
b"\xcf\xfa\xed\xfe",
b"\xca\xfe\xba\xbe", # fat/universal
}
# --------------------------------------------------------------------------- helpers
def glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Translate a path glob with `**` support into an anchored regex."""
out = []
i = 0
while i < len(pattern):
c = pattern[i]
if pattern.startswith("**/", i):
out.append("(?:.*/)?")
i += 3
continue
if pattern.startswith("**", i):
out.append(".*")
i += 2
continue
if c == "*":
out.append("[^/]*")
elif c == "?":
out.append("[^/]")
else:
out.append(re.escape(c))
i += 1
return re.compile("^" + "".join(out) + "$")
class PatternSet:
def __init__(self, patterns: list[str], subst: dict[str, str]):
self.raw = [expand(p, subst) for p in patterns]
self.compiled = [(p, glob_to_regex(p)) for p in self.raw]
def match(self, rel: str) -> str | None:
for raw, rx in self.compiled:
if rx.match(rel):
return raw
return None
def expand(pattern: str, subst: dict[str, str]) -> str:
for key, value in subst.items():
pattern = pattern.replace("{" + key + "}", value)
return pattern
def run(cmd: list[str], check: bool = True) -> str:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if check and proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr}")
return proc.stdout
def which_any(*names: str) -> str | None:
for name in names:
found = shutil.which(name)
if found:
return found
return None
def human(n: int) -> str:
value = float(n)
for unit in ("B", "KiB", "MiB", "GiB"):
if value < 1024 or unit == "GiB":
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
value /= 1024
return f"{n} B"
def binary_kind(path: Path) -> str | None:
try:
with path.open("rb") as fh:
magic = fh.read(4)
except OSError:
return None
if magic == ELF_MAGIC:
return "elf"
if magic in MACHO_MAGICS:
return "macho"
return None
# --------------------------------------------------------------------------- inspection
@dataclass
class BinaryInfo:
rel: str
kind: str # elf | macho
size: int
role: str = "unknown" # executable | shared
machine: str = ""
exports: list[str] = field(default_factory=list)
pie: bool | None = None
bind_now: bool | None = None
relro: bool | None = None
nx: bool | None = None
cet: bool | None = None
fortify_imports: int = 0
rpaths: list[str] = field(default_factory=list)
debug_sections: list[str] = field(default_factory=list)
has_symtab: bool = False
class ElfInspector:
def __init__(self) -> None:
self.readelf = which_any("readelf", "llvm-readelf", "eu-readelf")
self.nm = which_any("nm", "llvm-nm")
if not self.readelf or not self.nm:
raise SystemExit("readelf and nm are required to inspect ELF files")
def inspect(self, path: Path, rel: str) -> BinaryInfo:
info = BinaryInfo(rel=rel, kind="elf", size=path.stat().st_size)
header = run([self.readelf, "-W", "-h", str(path)])
etype = re.search(r"^\s*Type:\s*(\S+)", header, re.M)
machine = re.search(r"^\s*Machine:\s*(.+)$", header, re.M)
info.machine = machine.group(1).strip() if machine else ""
phdrs = run([self.readelf, "-W", "-l", str(path)], check=False)
has_interp = re.search(r"^\s*INTERP\s", phdrs, re.M) is not None
info.relro = re.search(r"^\s*GNU_RELRO\s", phdrs, re.M) is not None
stack = re.search(r"^\s*GNU_STACK\s.*\s(R?W?E?)\s+0x[0-9a-f]+\s*$", phdrs, re.M)
info.nx = bool(stack) and "E" not in stack.group(1)
dyn = run([self.readelf, "-W", "-d", str(path)], check=False)
flags_line = " ".join(
line for line in dyn.splitlines() if "(FLAGS" in line or "(BIND_NOW)" in line
)
info.bind_now = bool(re.search(r"\bBIND_NOW\b|\bNOW\b", flags_line))
is_pie_flag = "PIE" in flags_line
for match in re.finditer(r"\((?:RUNPATH|RPATH)\).*\[(.*)\]", dyn):
info.rpaths.extend(p for p in match.group(1).split(":") if p)
etype_value = etype.group(1) if etype else ""
if has_interp or etype_value == "EXEC":
info.role = "executable"
info.pie = etype_value == "DYN" or is_pie_flag
else:
info.role = "shared"
info.pie = None
sections = run([self.readelf, "-W", "-S", str(path)], check=False)
names = re.findall(r"^\s*\[\s*\d+\]\s+(\S+)", sections, re.M)
info.debug_sections = sorted(
{n for n in names if n.startswith(".debug_") or n.startswith(".zdebug_")}
)
info.has_symtab = ".symtab" in names
notes = run([self.readelf, "-W", "-n", str(path)], check=False)
if "X86-64" in info.machine or "x86-64" in info.machine.lower():
feature = re.search(r"x86 feature:\s*(.*)$", notes, re.M)
info.cet = bool(feature) and "IBT" in feature.group(1) and "SHSTK" in feature.group(1)
exported = run([self.nm, "-D", "--defined-only", "-C", str(path)], check=False)
info.exports = parse_nm(exported)
undefined = run([self.nm, "-D", "--undefined-only", str(path)], check=False)
info.fortify_imports = len(re.findall(r"__\w+_chk\b", undefined))
return info
class MachOInspector:
def __init__(self) -> None:
self.otool = which_any("otool", "llvm-otool")
self.nm = which_any("nm", "llvm-nm")
if not self.otool or not self.nm:
raise SystemExit("otool and nm are required to inspect Mach-O files")
def inspect(self, path: Path, rel: str) -> BinaryInfo:
info = BinaryInfo(rel=rel, kind="macho", size=path.stat().st_size)
header = run([self.otool, "-hv", str(path)], check=False)
if " EXECUTE " in header:
info.role = "executable"
info.pie = " PIE" in header
else:
info.role = "shared"
load = run([self.otool, "-l", str(path)], check=False)
for match in re.finditer(r"cmd LC_RPATH\n\s*cmdsize \d+\n\s*path (\S+)", load):
info.rpaths.append(match.group(1))
info.debug_sections = sorted(set(re.findall(r"sectname (__debug_\w+)", load)))
stabs = run([self.nm, "-ap", str(path)], check=False)
# Debug stabs (N_SO/N_OSO/N_FUN...) show as type "-". ld64 always emits one
# "OPT radr://5614542" marker that `strip -S` keeps; it carries no debug info.
info.has_symtab = any(
" - " in line and "radr://5614542" not in line for line in stabs.splitlines()
)
exported = run([self.nm, "-gUjC", str(path)], check=False)
info.exports = [line.strip() for line in exported.splitlines() if line.strip()]
undefined = run([self.nm, "-guj", str(path)], check=False)
info.fortify_imports = len(re.findall(r"___\w+_chk\b", undefined))
return info
def parse_nm(text: str) -> list[str]:
names = []
for line in text.splitlines():
parts = line.split(None, 2)
if len(parts) == 3:
_, typ, name = parts
elif len(parts) == 2:
typ, name = parts
else:
continue
if typ in ("U", "w", "v"):
continue
names.append(name)
return names
# --------------------------------------------------------------------------- staging
def meson_buildoptions(build_dir: Path) -> dict[str, object]:
raw = run(["meson", "introspect", "--buildoptions", str(build_dir)])
return {opt["name"]: opt["value"] for opt in json.loads(raw)}
def stage_build(build_dir: Path, destdir: Path, tags: str | None) -> None:
cmd = ["meson", "install", "-C", str(build_dir), "--destdir", str(destdir), "--no-rebuild"]
if tags:
cmd += ["--tags", tags]
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if proc.returncode != 0:
sys.stderr.write(proc.stdout)
raise SystemExit(f"meson install failed for {build_dir}")
def split_debug(install_root: Path, debug_dir: Path) -> None:
if not SPLIT_SCRIPT.exists():
print(f"note: {SPLIT_SCRIPT.relative_to(REPO_ROOT)} not found; skipping debug split")
return
proc = subprocess.run(
["bash", str(SPLIT_SCRIPT), str(install_root), str(debug_dir)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if proc.returncode != 0:
sys.stderr.write(proc.stdout)
raise SystemExit("debug split failed")
# --------------------------------------------------------------------------- checks
@dataclass
class Report:
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def error(self, msg: str) -> None:
self.errors.append(msg)
def warn(self, msg: str) -> None:
self.warnings.append(msg)
def first_budget(rules: list[dict], rel: str, subst: dict[str, str], key: str, kind: str = ""):
for rule in rules:
if kind and kind not in rule.get("kinds", [kind]):
continue
if glob_to_regex(expand(rule["pattern"], subst)).match(rel):
return rule[key], rule["pattern"]
return None, None
def collect_tree(install_root: Path) -> tuple[list[str], dict[str, Path]]:
rels: list[str] = []
paths: dict[str, Path] = {}
for dirpath, dirnames, filenames in os.walk(install_root):
dirnames.sort()
base = Path(dirpath)
for name in sorted(filenames) + sorted(d for d in dirnames if (base / d).is_symlink()):
p = base / name
rel = p.relative_to(install_root).as_posix()
rels.append(rel)
paths[rel] = p
return sorted(set(rels)), paths
def check(
destdir: Path,
prefix: str,
policy: dict,
subst: dict[str, str],
report: Report,
) -> list[BinaryInfo]:
install_root = destdir / prefix.lstrip("/")
if not install_root.is_dir():
report.error(f"install root missing: {install_root}")
return []
# Anything staged outside the prefix (e.g. /etc) must be explicitly allowed.
outside_allowed = PatternSet(policy.get("allowed_outside_prefix", []), subst)
for dirpath, _, filenames in os.walk(destdir):
for name in filenames:
p = Path(dirpath) / name
try:
p.relative_to(install_root)
except ValueError:
rel_abs = "/" + p.relative_to(destdir).as_posix()
if not outside_allowed.match(rel_abs):
report.error(f"path: installed outside prefix: {rel_abs}")
rels, paths = collect_tree(install_root)
allowed = PatternSet(policy["paths"]["allowed"], subst)
forbidden = PatternSet(policy["paths"].get("forbidden", []), subst)
forbidden_hits: dict[str, list[str]] = {}
for rel in rels:
hit = forbidden.match(rel)
if hit:
forbidden_hits.setdefault(hit, []).append(rel)
elif not allowed.match(rel):
report.error(f"path: not in the allowed runtime set: {rel}")
p = paths[rel]
if p.is_symlink():
target = (p.parent / os.readlink(p)).resolve()
try:
target.relative_to(install_root.resolve())
except ValueError:
report.error(f"path: symlink escapes the install prefix: {rel} -> {os.readlink(p)}")
if not target.exists():
report.error(f"path: dangling symlink: {rel} -> {os.readlink(p)}")
for pattern, hits in sorted(forbidden_hits.items()):
size = sum(paths[h].lstat().st_size for h in hits)
sample = ", ".join(hits[:3])
report.error(
f"path: {len(hits)} forbidden path(s) ({human(size)}) match {pattern}: {sample}"
f"{', ...' if len(hits) > 3 else ''}"
)
for req in policy["paths"].get("required", []):
rx = glob_to_regex(expand(req, subst))
if not any(rx.match(r) for r in rels):
report.error(f"path: required runtime path missing: {expand(req, subst)}")
# Binary inspection
inspectors: dict[str, object] = {}
infos: list[BinaryInfo] = []
for rel in rels:
p = paths[rel]
if p.is_symlink() or not p.is_file():
continue
kind = binary_kind(p)
if kind is None:
continue
if kind not in inspectors:
inspectors[kind] = ElfInspector() if kind == "elf" else MachOInspector()
infos.append(inspectors[kind].inspect(p, rel)) # type: ignore[attr-defined]
hard = policy.get("hardening", {})
strip_policy = policy.get("stripped", {})
forbidden_exports = [re.compile(x) for x in policy.get("forbidden_exports", [])]
export_rules = policy.get("export_budgets", [])
size_rules = policy.get("size_budgets", [])
exempt = PatternSet(policy.get("third_party_exempt", []), subst)
for info in infos:
is_third_party = exempt.match(info.rel) is not None
budget, pat = first_budget(size_rules, info.rel, subst, "max_bytes", info.kind)
if budget is not None and info.size > budget:
report.error(
f"size: {info.rel} is {human(info.size)} > budget {human(budget)} ({pat})"
)
# Mach-O executables keep every non-hidden global in their export trie
# whether or not anything can bind to it; the dynamic surface that
# matters on macOS is the dylib/bundle export list.
check_exports = not (info.kind == "macho" and info.role == "executable")
budget, pat = first_budget(export_rules, info.rel, subst, "max")
if check_exports and budget is not None and len(info.exports) > budget:
report.error(
f"exports: {info.rel} exports {len(info.exports)} symbols > budget {budget} ({pat})"
)
if check_exports and not is_third_party:
leaked: dict[str, list[str]] = {}
for name in info.exports:
for rx in forbidden_exports:
if rx.search(name):
leaked.setdefault(rx.pattern, []).append(name)
break
for pattern, names in sorted(leaked.items()):
sample = ", ".join(n if len(n) <= 60 else n[:57] + "..." for n in names[:3])
report.error(
f"exports: {info.rel} leaks {len(names)} symbol(s) matching /{pattern}/ "
f"(e.g. {sample})"
)
for rp in info.rpaths:
if not (rp.startswith("$ORIGIN") or rp.startswith("${ORIGIN}") or rp.startswith("@")):
report.error(f"rpath: {info.rel} has non-relative RUNPATH/RPATH entry {rp!r}")
if strip_policy.get("no_debug_sections") and info.debug_sections:
report.error(
f"stripped: {info.rel} ships debug sections ({', '.join(info.debug_sections[:4])}"
f"{'...' if len(info.debug_sections) > 4 else ''})"
)
if strip_policy.get("no_symtab") and info.has_symtab and not is_third_party:
report.error(f"stripped: {info.rel} ships a full symbol table (.symtab / stabs)")
if info.kind == "elf" and not is_third_party:
if hard.get("pie") and info.role == "executable" and not info.pie:
report.error(f"hardening: {info.rel} is not PIE")
if hard.get("bind_now") and not info.bind_now:
report.error(f"hardening: {info.rel} lacks BIND_NOW (full RELRO)")
if hard.get("relro") and not info.relro:
report.error(f"hardening: {info.rel} lacks a GNU_RELRO segment")
if hard.get("nx") and info.nx is False:
report.error(f"hardening: {info.rel} has an executable or missing GNU_STACK")
cet_mode = hard.get("cet_x86_64", "off")
if info.cet is False and cet_mode != "off":
msg = f"hardening: {info.rel} lacks x86 IBT/SHSTK notes (-fcf-protection)"
(report.error if cet_mode == "require" else report.warn)(msg)
fortify_mode = hard.get("fortify_imports", "off")
if info.role == "executable" and info.fortify_imports == 0 and fortify_mode != "off":
msg = f"hardening: {info.rel} imports no __*_chk functions (_FORTIFY_SOURCE inert?)"
(report.error if fortify_mode == "require" else report.warn)(msg)
if info.kind == "macho" and hard.get("pie") and info.role == "executable" and not info.pie:
report.error(f"hardening: {info.rel} is not PIE (MH_PIE)")
return infos
def print_table(infos: list[BinaryInfo], rels_total: int, total_bytes: int) -> None:
print(f"\nStaged runtime: {rels_total} paths, {human(total_bytes)} total")
if not infos:
return
width = max(len(i.rel) for i in infos)
print(
f"{'binary'.ljust(width)} {'bytes':>11} {'exports':>7} pie now relro nx cet "
f"chk debug symtab rpath"
)
def yn(v):
return "-" if v is None else ("y" if v else "n")
for i in sorted(infos, key=lambda x: x.rel):
print(
f"{i.rel.ljust(width)} {i.size:>11} {len(i.exports):>7} {yn(i.pie):>3} "
f"{yn(i.bind_now):>3} {yn(i.relro):>5} {yn(i.nx):>2} {yn(i.cet):>3} "
f"{i.fortify_imports:>3} {('y' if i.debug_sections else 'n'):>5} "
f"{('y' if i.has_symtab else 'n'):>6} {':'.join(i.rpaths) or '-'}"
)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--build-dir", type=Path, help="Meson build dir to stage with meson install")
src.add_argument("--stage", type=Path, help="already staged DESTDIR root to check")
ap.add_argument("--prefix", help="install prefix inside the stage (default: from meson)")
ap.add_argument("--libdir", help="libdir relative to prefix (default: from meson, else lib)")
ap.add_argument("--policy", type=Path, default=DEFAULT_POLICY)
ap.add_argument("--tags", help="meson install --tags subset to stage (default: everything)")
ap.add_argument("--no-split", action="store_true", help="skip the debug split before checking")
ap.add_argument("--keep-stage", type=Path, help="stage into this directory and keep it")
ap.add_argument("--json", type=Path, help="write a machine-readable report here")
args = ap.parse_args(argv)
policy = json.loads(args.policy.read_text())
prefix = args.prefix
libdir = args.libdir
tmp: tempfile.TemporaryDirectory[str] | None = None
if args.build_dir:
opts = meson_buildoptions(args.build_dir)
prefix = prefix or str(opts["prefix"])
libdir = libdir or str(opts["libdir"])
if args.keep_stage:
if args.keep_stage.exists():
shutil.rmtree(args.keep_stage)
destdir = args.keep_stage.resolve()
else:
tmp = tempfile.TemporaryDirectory(prefix="yams-install-surface-")
destdir = Path(tmp.name) / "stage"
stage_build(args.build_dir, destdir, args.tags)
if not args.no_split:
split_debug(destdir / prefix.lstrip("/"), destdir.parent / "debug-symbols")
else:
destdir = args.stage.resolve()
if not prefix:
ap.error("--prefix is required with --stage")
libdir = libdir or "lib"
if os.path.isabs(libdir):
libdir = os.path.relpath(libdir, prefix)
subst = {"libdir": libdir.strip("/")}
report = Report()
try:
infos = check(destdir, prefix, policy, subst, report)
install_root = destdir / prefix.lstrip("/")
rels, paths = collect_tree(install_root) if install_root.is_dir() else ([], {})
total = sum(paths[r].lstat().st_size for r in rels)
print(f"Install surface check: {install_root} (policy {args.policy.name}, "
f"libdir={subst['libdir']}, host={platform.system()}/{platform.machine()})")
print_table(infos, len(rels), total)
if args.json:
args.json.write_text(
json.dumps(
{
"errors": report.errors,
"warnings": report.warnings,
"files": rels,
"total_bytes": total,
"binaries": [
{
"path": i.rel,
"bytes": i.size,
"exports": len(i.exports),
"pie": i.pie,
"bind_now": i.bind_now,
"relro": i.relro,
"nx": i.nx,
"cet": i.cet,
"fortify_imports": i.fortify_imports,
"debug_sections": bool(i.debug_sections),
"symtab": i.has_symtab,
"rpaths": i.rpaths,
}
for i in infos
],
},
indent=2,
)
)
finally:
if tmp is not None:
tmp.cleanup()
for w in report.warnings:
print(f"WARN {w}")
for e in report.errors:
print(f"FAIL {e}")
if report.errors:
print(f"\ninstall surface: RED ({len(report.errors)} violation(s), {len(report.warnings)} warning(s))")
return 1
print(f"\ninstall surface: GREEN ({len(report.warnings)} warning(s))")
return 0
if __name__ == "__main__":
sys.exit(main())