168 lines
6.8 KiB
Python
168 lines
6.8 KiB
Python
"""Independent installed-distribution content measurement for SDK allowlists."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.util
|
|
from importlib import metadata
|
|
from pathlib import Path
|
|
|
|
|
|
def installed_distribution_digest(
|
|
distribution: metadata.Distribution | str,
|
|
*,
|
|
allow_editable: bool = False,
|
|
) -> str:
|
|
"""Hash installed package payload files using a stable path/length framing.
|
|
|
|
Distribution metadata is deliberately excluded: editable/non-editable
|
|
installers generate different RECORD and entry-point files for identical
|
|
package code. All source/native modules and package data below declared
|
|
top-level packages are included. Interpreter-generated ``__pycache__``
|
|
files are excluded because they are neither stable wheel payloads nor used
|
|
by the registry's cache-isolated discovery import.
|
|
"""
|
|
installed = (
|
|
metadata.distribution(distribution)
|
|
if isinstance(distribution, str)
|
|
else distribution
|
|
)
|
|
raw_top_level = installed.read_text("top_level.txt")
|
|
if raw_top_level is None:
|
|
raise ValueError("installed distribution does not declare top-level packages")
|
|
declared_top_levels = [
|
|
line.strip() for line in raw_top_level.splitlines() if line.strip()
|
|
]
|
|
if any(not value.isidentifier() for value in declared_top_levels):
|
|
raise ValueError("installed distribution declares an invalid top-level package")
|
|
top_levels = set(declared_top_levels)
|
|
if not top_levels:
|
|
raise ValueError("installed distribution has no measurable top-level package")
|
|
declared_files = tuple(installed.files or ())
|
|
editable_bootstrap = any(
|
|
Path(str(item)).name.startswith("__editable__")
|
|
and Path(str(item)).suffix == ".pth"
|
|
for item in declared_files
|
|
)
|
|
if editable_bootstrap and not allow_editable:
|
|
raise ValueError(
|
|
"editable workload installations are not accepted for secure discovery"
|
|
)
|
|
for item in declared_files:
|
|
relative = Path(str(item))
|
|
suffix = relative.suffix.lower()
|
|
if suffix == ".pth" and not allow_editable:
|
|
raise ValueError(
|
|
"installed workload distribution declares a .pth bootstrap"
|
|
)
|
|
if suffix in {".pyc", ".pyo"} and "__pycache__" not in relative.parts:
|
|
raise ValueError(
|
|
"installed workload distribution declares sourceless bytecode"
|
|
)
|
|
|
|
selected: list[tuple[str, Path]] = []
|
|
for top_level in sorted(top_levels):
|
|
root = Path(str(installed.locate_file(top_level)))
|
|
if not root.exists():
|
|
# PEP 660 editable distributions may expose source packages through
|
|
# a meta-path finder rather than a physical site-packages path.
|
|
spec = importlib.util.find_spec(top_level)
|
|
locations = (
|
|
tuple(spec.submodule_search_locations or ()) if spec is not None else ()
|
|
)
|
|
if len(locations) > 1:
|
|
raise ValueError(
|
|
"shared namespace packages are not supported for workload integrity"
|
|
)
|
|
if locations:
|
|
root = Path(locations[0])
|
|
if root.is_symlink():
|
|
raise ValueError(
|
|
"installed workload package root must not be a symbolic link"
|
|
)
|
|
if root.is_dir():
|
|
candidates = root.rglob("*")
|
|
for path in candidates:
|
|
if path.is_symlink():
|
|
raise ValueError(
|
|
"installed workload package contains a symbolic-link payload"
|
|
)
|
|
if not path.is_file():
|
|
continue
|
|
relative_parts = path.relative_to(root).parts
|
|
if "__pycache__" in relative_parts:
|
|
continue
|
|
if path.suffix.lower() in {".pyc", ".pyo"}:
|
|
raise ValueError(
|
|
"installed workload package contains sourceless bytecode"
|
|
)
|
|
relative = f"{top_level}/{path.relative_to(root).as_posix()}"
|
|
selected.append((relative, path))
|
|
continue
|
|
module = Path(str(installed.locate_file(top_level + ".py")))
|
|
if not module.exists():
|
|
spec = importlib.util.find_spec(top_level)
|
|
if spec is not None and spec.origin is not None:
|
|
module = Path(spec.origin)
|
|
if module.is_symlink() or not module.is_file():
|
|
raise ValueError(
|
|
"installed workload package contains a missing top-level payload"
|
|
)
|
|
selected.append((top_level + ".py", module))
|
|
# Include declared package data outside top-level import trees. Generated
|
|
# console wrappers and installer metadata are excluded; executable .pth and
|
|
# sourceless bytecode payloads were rejected above. Generated pycache
|
|
# entries are deliberately ignored and discovery imports from an empty
|
|
# cache prefix.
|
|
selected_names = {relative for relative, _ in selected}
|
|
metadata_root_names = {
|
|
Path(str(item)).parts[0]
|
|
for item in declared_files
|
|
if Path(str(item)).parts
|
|
and Path(str(item)).parts[0].endswith((".dist-info", ".egg-info"))
|
|
}
|
|
for item in declared_files:
|
|
relative = Path(str(item))
|
|
text = relative.as_posix()
|
|
if (
|
|
not relative.parts
|
|
or relative.parts[0] in metadata_root_names
|
|
or text.startswith("../../../bin/")
|
|
or "__pycache__" in relative.parts
|
|
):
|
|
continue
|
|
path = Path(str(installed.locate_file(item)))
|
|
if path.is_symlink():
|
|
raise ValueError(
|
|
"installed workload distribution contains a symbolic-link payload"
|
|
)
|
|
if not path.is_file() or text in selected_names:
|
|
continue
|
|
selected.append((text, path))
|
|
selected_names.add(text)
|
|
|
|
entry_point_payloads = [
|
|
(
|
|
f".entry-points/{entry_point.group}/{entry_point.name}",
|
|
entry_point.value.encode("utf-8"),
|
|
)
|
|
for entry_point in installed.entry_points
|
|
]
|
|
if not selected:
|
|
raise ValueError("installed distribution has no measurable package payload")
|
|
digest = hashlib.sha256()
|
|
for relative, path in sorted(selected):
|
|
name = relative.encode("utf-8")
|
|
payload = path.read_bytes()
|
|
digest.update(len(name).to_bytes(4, "big"))
|
|
digest.update(name)
|
|
digest.update(len(payload).to_bytes(8, "big"))
|
|
digest.update(payload)
|
|
for relative, payload in sorted(entry_point_payloads):
|
|
name = relative.encode("utf-8")
|
|
digest.update(len(name).to_bytes(4, "big"))
|
|
digest.update(name)
|
|
digest.update(len(payload).to_bytes(8, "big"))
|
|
digest.update(payload)
|
|
return "sha256:" + digest.hexdigest()
|