153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Audit accepted .debs for runtime ELF libraries accidentally shipped in -dev."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from project import ROOT as PROJECT
|
|
from project import WORK
|
|
|
|
|
|
def main():
|
|
artifacts = {}
|
|
records = {}
|
|
states = list((WORK / "state").glob("*.json"))
|
|
if not states:
|
|
raise ValueError("No accepted builds. Run build first.")
|
|
for path in states:
|
|
record = json.loads(path.read_text())
|
|
records[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
for name, digest in record["artifacts"].items():
|
|
if name.endswith(".deb") and "-dbgsym_" not in name:
|
|
assert artifacts.get(name, digest) == digest
|
|
artifacts[name] = digest
|
|
|
|
elfs = []
|
|
dev_symlinks = 0
|
|
for index, (name, expected) in enumerate(sorted(artifacts.items()), 1):
|
|
archive = WORK / "results" / name
|
|
with archive.open("rb") as stream:
|
|
assert hashlib.file_digest(stream, "sha256").hexdigest() == expected, name
|
|
package = name.split("_")[0]
|
|
development = package.endswith("-dev")
|
|
with tempfile.TemporaryDirectory(prefix="package-elf-", dir=WORK) as temporary:
|
|
paths = {}
|
|
process = subprocess.Popen(
|
|
["dpkg-deb", "--fsys-tarfile", str(archive)], stdout=subprocess.PIPE
|
|
)
|
|
try:
|
|
with tarfile.open(fileobj=process.stdout, mode="r|") as tar:
|
|
for member in tar:
|
|
path = member.name.removeprefix("./")
|
|
if development and member.issym() and path.endswith(".so"):
|
|
dev_symlinks += 1
|
|
if not member.isfile():
|
|
continue
|
|
stream = tar.extractfile(member)
|
|
head = stream.read(4)
|
|
if head != b"\x7fELF":
|
|
continue
|
|
local = Path(temporary) / str(len(paths))
|
|
with local.open("wb") as output:
|
|
output.write(head)
|
|
shutil.copyfileobj(stream, output)
|
|
paths[str(local)] = {
|
|
"package": package,
|
|
"path": path,
|
|
"development": development,
|
|
}
|
|
assert process.wait() == 0, name
|
|
finally:
|
|
process.stdout.close()
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
process.wait()
|
|
filenames = list(paths)
|
|
for offset in range(0, len(filenames), 100):
|
|
batch = filenames[offset : offset + 100]
|
|
text = subprocess.check_output(
|
|
["readelf", "--wide", "--dynamic", *batch],
|
|
text=True,
|
|
env={**os.environ, "LC_ALL": "C"},
|
|
)
|
|
sections = re.split(r"(?m)^File: (.+)\n", text)
|
|
entries = (
|
|
[(batch[0], text)]
|
|
if len(batch) == 1
|
|
else list(zip(sections[1::2], sections[2::2]))
|
|
)
|
|
assert len(entries) == len(batch) and {
|
|
filename for filename, _ in entries
|
|
} == set(batch), name
|
|
for filename, section in entries:
|
|
entry = paths[filename]
|
|
entry["needed"] = re.findall(r"\(NEEDED\).*?\[(.*?)\]", section)
|
|
entry["soname"] = re.findall(r"\(SONAME\).*?\[(.*?)\]", section)
|
|
elfs.append(entry)
|
|
if index % 50 == 0:
|
|
print(f"Checked {index}/{len(artifacts)} packages", flush=True)
|
|
|
|
providers = defaultdict(list)
|
|
candidates = []
|
|
for entry in elfs:
|
|
path = Path(entry["path"])
|
|
library = re.search(r"\.so(?:\..+)?$", path.name) and "lib" in path.parts
|
|
if not library:
|
|
continue
|
|
for key in set(entry["soname"] + [path.name]):
|
|
providers[key].append(entry)
|
|
if entry["development"]:
|
|
candidates.append(entry)
|
|
violations = []
|
|
for entry in elfs:
|
|
if entry["development"] or entry["package"] == "plasma-next-rust-build-tools":
|
|
continue
|
|
for needed in entry["needed"]:
|
|
matches = providers.get(needed, [])
|
|
if matches and all(provider["development"] for provider in matches):
|
|
violations.append(
|
|
{
|
|
"consumer_package": entry["package"],
|
|
"consumer": entry["path"],
|
|
"needed": needed,
|
|
"providers": matches,
|
|
}
|
|
)
|
|
report = {
|
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
"status": "findings" if candidates or violations else "passed",
|
|
"packages_checked": len(artifacts),
|
|
"elf_files_checked": len(elfs),
|
|
"development_so_symlinks": dev_symlinks,
|
|
"records": records,
|
|
"verified_artifacts": artifacts,
|
|
"real_libraries_in_dev": candidates,
|
|
"runtime_needs_dev_library": violations,
|
|
"limits": "Static DT_NEEDED and regular ELF library placement. Does not verify all dlopen/QML/script dependencies or loader search paths.",
|
|
}
|
|
target = WORK / "records/package-library-split-audit.json"
|
|
target.write_text(json.dumps(report, indent=2) + "\n")
|
|
print(
|
|
f"{len(artifacts)} packages, {len(elfs)} ELF files, {dev_symlinks} development .so symlinks."
|
|
)
|
|
for entry in candidates:
|
|
print(f"REVIEW {entry['package']}: {entry['path']}")
|
|
print(
|
|
f"{len(violations)} runtime DT_NEEDED references resolve only to -dev. Report: {target.relative_to(PROJECT)}"
|
|
)
|
|
return 1 if candidates or violations else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|