Harden release source execution

This commit is contained in:
2026-07-22 21:37:14 +02:00
parent 349a099e5a
commit 7ecf1f17b0
11 changed files with 3331 additions and 58 deletions

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import subprocess
@@ -165,16 +166,50 @@ def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["git", *args],
["/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args],
cwd=path,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
env=sanitized_git_environment(),
)
except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(["git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
return subprocess.CompletedProcess(["/usr/bin/git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
def sanitized_git_environment(
source: dict[str, str] | None = None,
) -> dict[str, str]:
"""Keep only deliberate process/auth inputs and neutralize Git redirection."""
environment = os.environ if source is None else source
result = {
key: environment[key]
for key in (
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"SSH_AUTH_SOCK",
)
if environment.get(key)
}
result.update(
{
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_CONFIG_COUNT": "0",
"GIT_NO_REPLACE_OBJECTS": "1",
"GIT_OPTIONAL_LOCKS": "0",
"GIT_PAGER": "cat",
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
"GIT_TERMINAL_PROMPT": "0",
"PATH": "/usr/bin:/bin",
}
)
return result
def git_error(result: subprocess.CompletedProcess[str]) -> str:

View File

@@ -44,7 +44,15 @@ def module_directory_payloads(
public_base_url: str = DEFAULT_PUBLIC_BASE_URL,
) -> tuple[tuple[Path, dict[str, Any]], ...]:
channel = validate_release_channel(channel)
generated_at = json_datetime(datetime.now(tz=UTC))
# Publication artifacts must be reproducible from the signed catalog so an
# interrupted push can later be reconciled against the immutable commit.
# Older/ad-hoc callers without a catalog timestamp retain the old fallback.
catalog_generated_at = catalog_payload.get("generated_at")
generated_at = (
catalog_generated_at
if isinstance(catalog_generated_at, str) and catalog_generated_at
else json_datetime(datetime.now(tz=UTC))
)
modules = module_rows(catalog_payload)
compatibility = compatibility_rows(modules)
signatures = signature_rows(catalog_payload, keyring_payload)

View File

@@ -1024,11 +1024,18 @@ def publication_runtime_trust_issues() -> tuple[str, ...]:
ancestor_issue = _operator_controlled_ancestor_issue(path)
if ancestor_issue:
return (f"{label}: {ancestor_issue}",)
executable_issue = _trusted_runtime_executable_issue(
Path(sys.executable), permitted_owners=runtime_owners
)
if executable_issue:
return (executable_issue,)
for executable, label in (
(Path(sys.executable), "Python executable"),
(Path("/usr/bin/git"), "Git executable"),
(Path("/usr/bin/ssh"), "SSH executable"),
):
executable_issue = _trusted_runtime_executable_issue(
executable,
permitted_owners=runtime_owners,
label=label,
)
if executable_issue:
return (executable_issue,)
required = (
(META_ROOT, True, "meta repository root"),
(META_ROOT / "repositories.json", False, "repository registry"),
@@ -1093,32 +1100,32 @@ def _loaded_package_root(module: object, *, label: str) -> Path:
def _trusted_runtime_executable_issue(
path: Path, *, permitted_owners: set[int]
path: Path, *, permitted_owners: set[int], label: str = "Python executable"
) -> str | None:
executable = path.absolute()
ancestor_issue = _operator_controlled_ancestor_issue(executable.parent)
if ancestor_issue:
return f"Python executable: {ancestor_issue}"
return f"{label}: {ancestor_issue}"
try:
link_metadata = executable.lstat()
except OSError:
return f"Python executable cannot be inspected safely: {executable}"
return f"{label} cannot be inspected safely: {executable}"
if stat.S_ISLNK(link_metadata.st_mode):
if link_metadata.st_uid not in permitted_owners:
return f"Python executable symlink has an untrusted owner: {executable}"
return f"{label} symlink has an untrusted owner: {executable}"
try:
target = executable.resolve(strict=True)
except OSError:
return f"Python executable symlink cannot be resolved safely: {executable}"
return f"{label} symlink cannot be resolved safely: {executable}"
else:
target = executable
ancestor_issue = _operator_controlled_ancestor_issue(target.parent)
if ancestor_issue:
return f"Python executable target: {ancestor_issue}"
return f"{label} target: {ancestor_issue}"
return _operator_control_issue(
target,
directory=False,
label="Python executable target",
label=f"{label} target",
permitted_owners=permitted_owners,
)
@@ -1866,7 +1873,9 @@ def _sanitized_git_environment(
source = base if base is not None else os.environ
environment = {
key: value for key, value in source.items() if not key.startswith("GIT_")
key: source[key]
for key in ("HOME", "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK")
if source.get(key)
}
environment.update(
{
@@ -1875,7 +1884,9 @@ def _sanitized_git_environment(
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_NO_REPLACE_OBJECTS": "1",
"GIT_PAGER": "cat",
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
"GIT_TERMINAL_PROMPT": "0",
"PATH": "/usr/bin:/bin",
}
)
if isolate_config:
@@ -1911,7 +1922,7 @@ def _git_bytes(
)
result = subprocess.run(
[
"git",
"/usr/bin/git",
"-C",
str(path),
"-c",

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@ import re
import subprocess
import sys
from .git_state import collect_repository_snapshot, git_text
from .git_state import (
collect_repository_snapshot,
git_text,
sanitized_git_environment,
)
from .model import RepositorySnapshot
from .repository_push import command_text, compact_output
from .version_alignment import repository_version_issues, selected_release_webui_bundle_issues
@@ -397,7 +401,16 @@ def manifest_shape_gate_issue(workspace: Path) -> str | None:
"--workspace-root",
str(workspace),
)
result = run(command, cwd=META_ROOT, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
result = run(
command,
cwd=META_ROOT,
env={
key: value
for key, value in os.environ.items()
if not key.startswith("GIT_")
}
| {"PYTHONDONTWRITEBYTECODE": "1"},
)
if result.returncode == 0:
return None
detail = compact_output(result.stderr) or compact_output(result.stdout)
@@ -463,16 +476,26 @@ def run(
timeout: int = 120,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
effective_command = command
effective_environment = env
if command and Path(command[0]).name == "git":
effective_command = (
"/usr/bin/git",
"-c",
"core.hooksPath=/dev/null",
*command[1:],
)
effective_environment = sanitized_git_environment(env)
try:
return subprocess.run(
command,
effective_command,
cwd=cwd,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
env=env,
env=effective_environment,
)
except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out")

View File

@@ -33,10 +33,11 @@ from .catalog_entry_synthesis import (
validate_initial_entry_closure,
)
from .contracts import collect_contracts
from .git_state import collect_repository_snapshot
from .git_state import collect_repository_snapshot, git_text
from .model import CatalogEntryChange, SelectiveCatalogCandidate
from .module_directory import write_module_directory
from .source_provenance import (
SourceTagProvenanceIssue,
catalog_source_selection,
selected_source_provenance,
source_tag_provenance_issues,
@@ -45,7 +46,12 @@ from .version_alignment import (
selected_release_webui_bundle_issues,
selected_repository_version_issues,
)
from .workspace import load_repository_specs, resolve_workspace_root, website_root
from .workspace import (
load_repository_specs,
resolve_repo_path,
resolve_workspace_root,
website_root,
)
GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
MAX_BASE_JSON_BYTES = 16 * 1024 * 1024
@@ -77,6 +83,7 @@ def build_selective_catalog_candidate(
check_public: bool = True,
write_summary: bool = True,
python_artifacts: dict[str, Path | str] | None = None,
frozen_source_provenance: dict[str, dict[str, str]] | None = None,
) -> SelectiveCatalogCandidate:
channel = validate_release_channel(channel)
if not repo_versions:
@@ -88,15 +95,23 @@ def build_selective_catalog_candidate(
workspace = resolve_workspace_root(workspace_root)
enforce_selected_version_alignment(repo_versions=repo_versions, workspace=workspace)
enforce_selected_source_provenance(
repo_versions=repo_versions,
workspace=workspace,
remote=source_remote,
)
selected_provenance = selected_source_provenance(
repo_versions=repo_versions,
workspace=workspace,
)
if frozen_source_provenance is None:
enforce_selected_source_provenance(
repo_versions=repo_versions,
workspace=workspace,
remote=source_remote,
)
selected_provenance = selected_source_provenance(
repo_versions=repo_versions,
workspace=workspace,
)
else:
selected_provenance = enforce_frozen_selected_source_provenance(
repo_versions=repo_versions,
expected_provenance=frozen_source_provenance,
workspace=workspace,
remote=source_remote,
)
web_root = website_root(workspace)
authenticated_base = load_authenticated_catalog_base(
base_catalog=base_catalog,
@@ -132,7 +147,10 @@ def build_selective_catalog_candidate(
]
candidate["release"] = release
repo_contracts = contracts_by_repo(workspace)
repo_contracts = contracts_by_repo(
workspace,
selected_repositories=set(repo_versions),
)
changes = apply_repo_updates(
candidate,
repo_versions=repo_versions,
@@ -151,6 +169,7 @@ def build_selective_catalog_candidate(
payload=candidate,
workspace=workspace,
remote=source_remote,
require_selected_heads=frozen_source_provenance is None,
)
output_root = resolve_output_dir(output_dir=output_dir, channel=channel, generated_at=generated_at)
@@ -283,11 +302,107 @@ def enforce_selected_source_provenance(
)
def enforce_frozen_selected_source_provenance(
*,
repo_versions: dict[str, str],
expected_provenance: dict[str, dict[str, str]],
workspace: Path,
remote: str = "origin",
) -> dict[str, dict[str, str]]:
"""Verify detached, clean source checkouts against durable object receipts.
Normal interactive candidate generation still requires a named, current
source branch. The durable executor instead operates on isolated clones of
exact annotated origin tags, so it supplies the commit and tag object that
must be present at each detached HEAD.
"""
if set(expected_provenance) != set(repo_versions):
raise ValueError(
"Frozen source provenance must cover exactly the selected repositories."
)
expected_commits: dict[str, str] = {}
expected_tag_objects: dict[str, str] = {}
for repo in sorted(repo_versions):
identity = expected_provenance.get(repo)
commit = identity.get("commit_sha") if isinstance(identity, dict) else None
tag_object = (
identity.get("tag_object_sha") if isinstance(identity, dict) else None
)
if (
not isinstance(identity, dict)
or set(identity) != {"commit_sha", "tag_object_sha"}
or not isinstance(commit, str)
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit) is None
or not isinstance(tag_object, str)
or re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", tag_object) is None
):
raise ValueError(
f"Frozen source provenance is malformed for {repo}."
)
expected_commits[repo] = commit
expected_tag_objects[repo] = tag_object
failures = list(
source_tag_provenance_issues(
repo_versions=repo_versions,
workspace=workspace,
remote=remote,
expected_commits=expected_commits,
expected_tag_objects=expected_tag_objects,
)
)
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
for repo, version in sorted(repo_versions.items()):
spec = specs.get(repo)
if spec is None:
continue
path = resolve_repo_path(spec, workspace)
snapshot = collect_repository_snapshot(
spec,
workspace_root=workspace,
target_tag=f"v{version.removeprefix('v')}",
online=False,
)
if snapshot.dirty_entries:
failures.append(
SourceTagProvenanceIssue(
repo,
f"v{version.removeprefix('v')}",
"frozen source worktree is not clean",
)
)
head = git_text(path, "rev-parse", "--verify", "HEAD")
if head != expected_commits[repo]:
failures.append(
SourceTagProvenanceIssue(
repo,
f"v{version.removeprefix('v')}",
"frozen source HEAD does not match its durable commit receipt",
)
)
if failures:
raise ValueError(
"Frozen source provenance gate failed: "
+ "; ".join(issue.describe() for issue in failures)
)
observed = selected_source_provenance(
repo_versions=repo_versions,
workspace=workspace,
)
if observed != expected_provenance:
raise ValueError(
"Frozen source object identities changed during candidate synthesis."
)
return observed
def enforce_complete_catalog_source_provenance(
*,
payload: object,
workspace: Path,
remote: str = "origin",
require_selected_heads: bool = True,
) -> None:
selection = catalog_source_selection(payload)
failures = [*selection.issues]
@@ -296,7 +411,9 @@ def enforce_complete_catalog_source_provenance(
repo_versions=selection.all_versions,
workspace=workspace,
remote=remote,
require_head_repos=selection.selected_versions,
require_head_repos=(
selection.selected_versions if require_selected_heads else ()
),
expected_commits=selection.selected_commits,
expected_tag_objects=selection.selected_tag_objects,
)
@@ -408,7 +525,9 @@ def read_bounded_json_source(source: Path | str, *, label: str) -> object:
raise ValueError(f"{label.capitalize()} is not valid UTF-8 JSON.") from exc
def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes:
def _read_regular_file_without_symlinks(
path: Path, *, label: str, require_private_owner: bool = False
) -> bytes:
absolute = path.absolute()
parts = absolute.parts[1:]
if not parts:
@@ -439,6 +558,13 @@ def _read_regular_file_without_symlinks(path: Path, *, label: str) -> bytes:
raise ValueError(
f"{label.capitalize()} must be a bounded regular file."
)
if require_private_owner and (
initial.st_uid != os.geteuid() or stat.S_IMODE(initial.st_mode) & 0o077
):
raise ValueError(
f"{label.capitalize()} must be owned by the current operator "
"and inaccessible to other users."
)
chunks: list[bytes] = []
remaining = initial.st_size
while remaining:
@@ -474,8 +600,14 @@ def next_sequence(payload: dict[str, Any], *, generated_at: datetime) -> int:
return max(current + 1, timestamp_sequence)
def contracts_by_repo(workspace: Path) -> dict[str, Any]:
specs = load_repository_specs(include_website=False)
def contracts_by_repo(
workspace: Path, *, selected_repositories: set[str] | None = None
) -> dict[str, Any]:
specs = tuple(
spec
for spec in load_repository_specs(include_website=False)
if selected_repositories is None or spec.name in selected_repositories
)
snapshots = tuple(
collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False)
for spec in specs
@@ -719,7 +851,11 @@ def parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
raise ValueError("Catalog signing key ID is not a bounded opaque ID.")
path = Path(path_text).expanduser()
private_key = serialization.load_pem_private_key(
_read_regular_file_without_symlinks(path, label="catalog signing key"),
_read_regular_file_without_symlinks(
path,
label="catalog signing key",
require_private_owner=True,
),
password=None,
)
if not isinstance(private_key, Ed25519PrivateKey):

View File

@@ -12,7 +12,11 @@ import tomllib
from typing import Iterable
from urllib.parse import urlsplit
from .git_state import collect_repository_snapshot, git_text
from .git_state import (
collect_repository_snapshot,
git_text,
sanitized_git_environment,
)
from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
from .version_alignment import repository_version_issues
from .workspace import load_repository_specs, resolve_repo_path
@@ -415,16 +419,17 @@ def _git_file(path: Path, tag: str, name: str) -> str | None:
def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
("git", *args),
("/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args),
cwd=path,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=30,
env=sanitized_git_environment(),
)
except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(("git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
return subprocess.CompletedProcess(("/usr/bin/git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:

View File

@@ -9,7 +9,7 @@ import re
import subprocess
import tomllib
from .git_state import collect_versions
from .git_state import collect_versions, sanitized_git_environment
from .workspace import load_repository_specs, resolve_repo_path
@@ -684,12 +684,13 @@ def _git_tag_commit(repo_path: Path, tag: str) -> str | None:
if not (repo_path / ".git").exists():
return None
result = subprocess.run(
["git", "-C", str(repo_path), "rev-list", "-n", "1", tag],
["/usr/bin/git", "-C", str(repo_path), "rev-list", "-n", "1", tag],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=10,
env=sanitized_git_environment(),
)
value = result.stdout.strip()
return value if result.returncode == 0 and value else None
@@ -699,12 +700,13 @@ def _git_tag_file(repo_path: Path, tag: str, relative_path: str) -> str | None:
if not (repo_path / ".git").exists():
return None
result = subprocess.run(
["git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"],
["/usr/bin/git", "-C", str(repo_path), "show", f"{tag}:{relative_path}"],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=10,
env=sanitized_git_environment(),
)
return result.stdout if result.returncode == 0 else None