diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 1acf9216d..9afadf817 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -152,6 +152,11 @@ jobs: - name: Prove the image installs and checks the fixed Brotli decoder run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py + - name: Prove the PHP image refuses an affected APT client before download + run: | + python3 -m unittest tests/Unit/Support/php_apt_test.py + python3 scripts/ci/verify-php-apt-source.py + - name: Check published failover image identity and recovery result gates run: python3 -m unittest discover -s tests/Unit/Support -p 'single_region_failover*test.py' diff --git a/.github/workflows/public-boundary.yml b/.github/workflows/public-boundary.yml index 516f5bbcb..5dc955ae8 100644 --- a/.github/workflows/public-boundary.yml +++ b/.github/workflows/public-boundary.yml @@ -13,11 +13,13 @@ jobs: scan: name: Scan public boundary runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false - name: Verify metadata scan failures run: bash tests/Unit/Support/public_boundary_test.sh diff --git a/Dockerfile b/Dockerfile index 2151e7c11..4349136b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,15 @@ RUN git clone --depth 1 --branch "${PHPREDIS_VERSION}" https://github.com/phpred fi # The fixed official Apache runtime is pinned for both release architectures. -FROM php@sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef AS base +FROM php@sha256:3614314e50a9b17a0c317dfd100c6346d8881d6565e713556b7a4e8eb3ec4505 AS base + +# Bind the fixed APT parent and read the effective client before any package +# operation. The source proof is docker/apt/parent.v1.json. No self-upgrade. +COPY docker/apt/require-fixed-apt.sh /usr/local/lib/server-apt/require-fixed-apt.sh # Both web and CLI roles require the fixed PHP runtime, including cached builds. -RUN php -r 'if (PHP_MAJOR_VERSION !== 8 || PHP_MINOR_VERSION !== 3 || PHP_VERSION_ID < 80336) { \ +RUN /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh /usr/local/lib/server-apt/require-fixed-apt.sh \ + && php -r 'if (PHP_MAJOR_VERSION !== 8 || PHP_MINOR_VERSION !== 3 || PHP_VERSION_ID < 80336) { \ fwrite(STDERR, "Patched PHP 8.3.36 or later on the 8.3 line is required; found ".PHP_VERSION.PHP_EOL); \ exit(1); }' @@ -100,7 +105,8 @@ COPY scripts/ci/WorkflowPackageAuthority.php scripts/ci/WorkflowPackageAuthority COPY scripts/ci/resolve-workflow-package-authority.php scripts/ci/resolve-workflow-package-authority.php COPY scripts/ci/prepare-release-workflow-composer-metadata.php scripts/ci/prepare-release-workflow-composer-metadata.php -RUN apt-get update \ +RUN /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh /usr/local/lib/server-apt/require-fixed-apt.sh \ + && apt-get update \ && apt-get install -y --no-install-recommends git \ && rm -rf /var/lib/apt/lists/* \ && eval "$(php scripts/ci/resolve-workflow-package-authority.php --format=shell)" \ diff --git a/docker/apt/README.md b/docker/apt/README.md new file mode 100644 index 000000000..7473fbdb6 --- /dev/null +++ b/docker/apt/README.md @@ -0,0 +1,28 @@ +The PHP production Dockerfile pins the official PHP 8.3.36 Apache/Trixie index +in `parent.v1.json`. Both selected platform inventories report APT and libapt +3.0.4, the Debian 13 fix for +[CVE-2026-109518](https://lists.debian.org/debian-security-announce/2026/msg00526.html). +The recorded child, config, companion and complete SPDX predicate hashes were +independently checked, including each statement's child subject. These public +metadata facts do not constitute an installed-image or build qualification. + +Before the first command in both the base and production stages, the Dockerfile +runs `require-fixed-apt.sh` through an absolute, empty environment. It reads the +actual Debian release, architecture, fully installed APT/libapt records and +effective APT client version with fixed system commands. Unknown, missing, +mixed, unconfigured or below-floor observations refuse before a package +operation. The helper has no inputs, download or self-upgrade path. + +Downstream source consumers can run `python3 scripts/ci/verify-php-apt-source.py` +against their selected Server checkout before a build. It checks the reviewed +parent/guard and complete Dockerfile bytes, literal parent and inherited +first-RUN ordering. A changed Dockerfile requires renewed review and binding. +It performs no network, image or package operation and reports no installed observation. +Consumers must additionally bind the exact accepted Server commit and verifier +bytes through their reviewed source authority. A passing source prerequisite +does not authorize a build, publication or runtime operation. + +The source guard deliberately covers this production Dockerfile. The separate +feature-workflow parent and historical performance/conformance Dockerfiles have +their own subjects and acceptance records. The historical parents remain +unqualified for future networked APT until their own prerequisites are met. diff --git a/docker/apt/parent.v1.json b/docker/apt/parent.v1.json new file mode 100644 index 000000000..c8118d9d9 --- /dev/null +++ b/docker/apt/parent.v1.json @@ -0,0 +1,35 @@ +{ + "schema": "durable-workflow.server-php-apt-parent.v1", + "image": "docker.io/library/php", + "php": "8.3.36-apache-trixie", + "os": "debian", + "release": "13", + "floor": "3.0.4", + "index": "sha256:3614314e50a9b17a0c317dfd100c6346d8881d6565e713556b7a4e8eb3ec4505", + "platforms": { + "linux/amd64": { + "child": "sha256:8732e645530351f2f16aa7dd7619a44dad614bcfde6b4eb994f9a228d24f9b98", + "config": "sha256:d86e74262d39379c3cd57031d8c65119dd5f317d1047aed19b8f4faa836bc267", + "attestation": "sha256:90503a9159222147e2e64757068b241eb4f0fc10b7d0f68dc948aefee753f44a", + "spdx": "sha256:22d23f0d84be8fe7dd7a09285b7e5d510a55fe6b79a42f226af0090daebad5de", + "spdx_bytes": 7219858, + "packages": { + "apt": "3.0.4", + "libapt-pkg7.0": "3.0.4" + } + }, + "linux/arm64": { + "child": "sha256:ccdca92cf5c884a7d6d8d261d983516bcdbe9bec5055e9be869362a688960c6b", + "config": "sha256:32e2e59c07397349a251030797bf34c513f5a99532765f62f0e0dfb1753ece75", + "attestation": "sha256:7abf82b397d37e3955d90f9111ba2dd937595a825d17fc7c192f931f391a1bf2", + "spdx": "sha256:0b41c46fd80286de185d04e69d7e9b5dbc3875fed0bdc93d4ef5a04f6c40b125", + "spdx_bytes": 7317146, + "packages": { + "apt": "3.0.4", + "libapt-pkg7.0": "3.0.4" + } + } + }, + "advisory": "https://lists.debian.org/debian-security-announce/2026/msg00526.html", + "source_only": true +} diff --git a/docker/apt/require-fixed-apt.sh b/docker/apt/require-fixed-apt.sh new file mode 100644 index 000000000..f3433da23 --- /dev/null +++ b/docker/apt/require-fixed-apt.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Read-only first-RUN boundary for the exact Trixie production parent (amd64/arm64). +# The Dockerfile invokes this through an absolute, credential-free environment. +set -eu + +refuse() { + printf '%s\n' 'Fixed APT prerequisite refused; no package operation was started.' >&2 + exit 1 +} + +[ "$#" -eq 0 ] || refuse +[ "$(/usr/bin/sed -n 's/^ID=//p' /etc/os-release)" = debian ] || refuse +[ "$(/usr/bin/sed -n 's/^VERSION_ID=//p' /etc/os-release)" = '"13"' ] || refuse +architecture=$(/usr/bin/timeout 10 /usr/bin/dpkg --print-architecture) || refuse +case "$architecture" in amd64|arm64) ;; *) refuse ;; esac + +installed_version() { + record=$(/usr/bin/timeout 10 /usr/bin/dpkg-query --show \ + "--showformat=\${Status}\t\${Version}\n" "$1") || refuse + prefix=$(printf 'install ok installed\t') + case "$record" in "$prefix"*) version=${record#"$prefix"} ;; *) refuse ;; esac + case "$version" in ''|[!0-9]*|*[!0-9A-Za-z.+:~-]*) refuse ;; esac + [ "${#version}" -le 100 ] || refuse + printf '%s' "$version" +} + +apt_version=$(installed_version apt) || refuse +libapt_version=$(installed_version libapt-pkg7.0) || refuse +[ "$apt_version" = "$libapt_version" ] || refuse +/usr/bin/timeout 10 /usr/bin/dpkg --compare-versions "$apt_version" ge 3.0.4 || refuse +client=$(/usr/bin/timeout 10 /usr/bin/apt-get --version) || refuse +[ "$(printf '%s\n' "$client" | /usr/bin/sed -n '1p')" = "apt $apt_version ($architecture)" ] || refuse +printf 'fixed-apt os=debian release=13 arch=%s apt=%s libapt-pkg7.0=%s\n' "$architecture" "$apt_version" "$libapt_version" diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 9a79a2a15..83db79fb7 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -1217,6 +1217,42 @@ def spans_overlap(first: tuple[int, int], second: tuple[int, int]) -> bool: return first[0] < second[1] and second[0] < first[1] +def isolated_source_check( + root: Path, + program: str, + arguments: str, +) -> tuple[str, bool, tuple[int, int]] | None: + """Model only closed isolated Python execution and shell diagnostics. + + An executed Python helper remains a transitive inspection target. Shell + syntax/lint operands are data only for these exact argument positions; + accepting one occurrence must not exempt another invocation of the file. + """ + + if program != "/usr/bin/env": + return None + match = re.fullmatch( + r"\s+-i PATH=/usr/bin:/bin LC_ALL=C " + r"(?P/usr/bin/python3 -I -S -B|/bin/sh -n|/usr/bin/shellcheck) " + r"(?P(?:[\w.-]+/)*[\w.-]+\.(?:py|sh))\s*", + arguments, + ) + if match is None: + return None + path = match.group("path") + target = root / path + if ( + any(part in {".", ".."} for part in path.split("/")) + or not target.is_file() + or not target.resolve().is_relative_to(root.resolve()) + ): + return None + executes = match.group("tool").startswith("/usr/bin/python3 ") + if target.suffix != (".py" if executes else ".sh"): + return None + return path, executes, match.span("path") + + def workflow_run_blocks(job_source: str) -> list[str]: """Return only shell source from run steps in one workflow job.""" @@ -2051,6 +2087,8 @@ def unclassified_dynamic_invocations( raw_arguments = invocation.group("arguments") if is_assignment_word(raw_program): continue + if isolated_source_check(root, raw_program, raw_arguments) is not None: + continue program, tokens, command_string = unwrap_command_launcher( raw_program.strip("\"'"), @@ -2147,6 +2185,20 @@ def workflow_script_references( allowed_script_spans: list[tuple[int, int]] = [] violations = unclassified_dynamic_invocations(root, job_source) + for invocation in WORKFLOW_COMMAND_INVOCATION.finditer(invocation_source): + if starts_inside_shell_quote(invocation_source, invocation.start()): + continue + check = isolated_source_check( + root, invocation.group("program"), invocation.group("arguments") + ) + if check is None: + continue + path, executes, span = check + if executes: + references.add(path) + offset = invocation.start("arguments") + allowed_script_spans.append((offset + span[0], offset + span[1])) + for reference in WORKFLOW_SCRIPT_REFERENCE.finditer(invocation_source): if ( invocation_source[reference.start()] == "(" diff --git a/scripts/ci/verify-php-apt-source.py b/scripts/ci/verify-php-apt-source.py new file mode 100644 index 000000000..a85a7ee1f --- /dev/null +++ b/scripts/ci/verify-php-apt-source.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Read-only source prerequisite; never builds, fetches or installs anything.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re + + +INDEX = "sha256:3614314e50a9b17a0c317dfd100c6346d8881d6565e713556b7a4e8eb3ec4505" +PROOF_SHA256 = "09078d7166cd57d8781ef344f5f7b2f69712ace54aa867d320d99954f039cd60" +GUARD_SHA256 = "082e4bcf3aebaf8da74658c2f15c4b6df259e0cb8f2b09e4e16a737e223a94a3" +DOCKERFILE_SHA256 = "d70f15b575b98f0cfd6c5b6abc95a9d53b9560b03aeed9c1996fec46d4d9d41a" +GUARD = "/usr/local/lib/server-apt/require-fixed-apt.sh" +PREFIX = "/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh " + GUARD + + +def require(condition: bool, reason: str) -> None: + if not condition: + raise ValueError(reason) + + +def instructions(source: str): + logical = "" + for line in source.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + continued = line.endswith("\\") + logical += (line[:-1].rstrip() if continued else line) + " " + if not continued: + verb, _, value = logical.strip().partition(" ") + yield verb.upper(), value + logical = "" + require(not logical, "unterminated Dockerfile instruction") + + +def require_source_boundary(source: str, proof: bytes, guard: bytes) -> dict: + # These hashes bind reviewed source inputs. They do not authenticate an + # arbitrary downloaded inventory, installed image or runtime observation. + require(hashlib.sha256(proof).hexdigest() == PROOF_SHA256, "unreviewed parent inventory") + require(hashlib.sha256(guard).hexdigest() == GUARD_SHA256, "unreviewed effective-client guard") + value = json.loads(proof) + stage, seen, first_run, copied = "", set(), set(), False + for verb, argument in instructions(source): + if verb == "FROM": + parts = argument.split() + require(len(parts) in (1, 3), "unsupported stage declaration") + if len(parts) == 3: + require(parts[1] == "AS" and parts[2] not in seen, "ambiguous stage") + stage = parts[2] + else: + stage = parts[0] + seen.add(stage) + if stage == "base": + require(parts[0] == "php@" + INDEX, "unproved PHP parent") + if stage == "production": + require(parts[0] == "base", "production must inherit the guarded base") + elif verb == "COPY" and argument == "docker/apt/require-fixed-apt.sh " + GUARD: + require(stage == "base" and stage not in first_run and not copied, "late or duplicate guard copy") + copied = True + elif verb == "RUN": + if stage not in first_run and stage in ("base", "production"): + require(copied and argument.startswith(PREFIX + " && "), "first RUN lacks the closed guard") + first_run.add(stage) + if re.search(r"\b(?:apt|apt-get|docker-php-ext-install|install-php-extensions)\b", argument): + require(stage in ("base", "production"), "unqualified package stage") + require({"base", "production"} <= first_run and copied, "missing guarded runtime stages") + require(hashlib.sha256(source.encode("utf-8")).hexdigest() == DOCKERFILE_SHA256, + "unreviewed Dockerfile bytes") + return value + + +def checked_file(root: Path, relative: str, limit: int) -> bytes: + path = root / relative + require(path.is_file() and not path.is_symlink() and path.resolve().is_relative_to(root), + "source path escapes checkout or is not a regular file") + with path.open("rb") as stream: + data = stream.read(limit + 1) + require(0 < len(data) <= limit, "source input bound") + return data + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) + args = parser.parse_args() + root = args.root.resolve() + proof = require_source_boundary( + checked_file(root, "Dockerfile", 65536).decode("utf-8", errors="strict"), + checked_file(root, "docker/apt/parent.v1.json", 16384), + checked_file(root, "docker/apt/require-fixed-apt.sh", 8192)) + print(json.dumps({"source_prerequisite": "pass", "index": proof["index"], + "platforms": sorted(proof["platforms"]), "installed_observation": False})) + + +if __name__ == "__main__": + main() diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index a6d5226cd..ac4b1ff3c 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -194,7 +194,7 @@ { "type": "server-verify", "line": "RUN server-verify-trust final \\", - "distance": 77 + "distance": 78 } ] }, diff --git a/tests/Unit/Support/docker_workflow_isolation_policy_test.py b/tests/Unit/Support/docker_workflow_isolation_policy_test.py index 53941bd4c..612b43b3a 100644 --- a/tests/Unit/Support/docker_workflow_isolation_policy_test.py +++ b/tests/Unit/Support/docker_workflow_isolation_policy_test.py @@ -467,6 +467,137 @@ def test_scans_literal_helpers_behind_static_launchers(self) -> None: result.stderr, ) + def test_accepts_closed_isolated_source_execution_and_diagnostics(self) -> None: + result = self.run_policy( + """ + jobs: + source: + steps: + - run: | + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/python3 -I -S -B tests/check.py + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh -n docker/check.sh + /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/shellcheck docker/check.sh + """, + { + "tests/check.py": "print('source check')", + "docker/check.sh": "docker run -d --name shared-helper image", + }, + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_isolated_python_still_inspects_transitive_docker_helpers(self) -> None: + result = self.run_policy( + """ + jobs: + source: + steps: + - run: /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/python3 -I -S -B scripts/check.py + """, + { + "scripts/check.py": "import subprocess\nsubprocess.run(['bash', 'scripts/unsafe.sh'])", + "scripts/unsafe.sh": "docker run -d --name shared-helper image", + }, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("scripts/unsafe.sh", result.stderr) + self.assertIn("docker run --name must use a scoped variable", result.stderr) + self.assertNotIn("unclassified command /usr/bin/env", result.stderr) + + def test_diagnostic_occurrence_does_not_exempt_executed_shell_file(self) -> None: + for execution in ( + "sh docker/check.sh", + "/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh docker/check.sh", + ): + with self.subTest(execution=execution): + result = self.run_policy( + f""" + jobs: + source: + steps: + - run: /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh -n docker/check.sh + - run: /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/shellcheck docker/check.sh + - run: {execution} + """, + {"docker/check.sh": "docker run -d --name shared-helper image"}, + ) + self.assertNotEqual(0, result.returncode) + + def test_isolated_source_model_refuses_changed_environment_flags_or_operands(self) -> None: + command = "/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/python3 -I -S -B scripts/check.py" + mutations = [ + command.replace(flag + " ", "", 1) for flag in ("-i", "-I", "-S", "-B") + ] + [ + command.replace("/usr/bin/env", "/unreviewed/env", 1), + command.replace("PATH=/usr/bin:/bin", "PATH=/unreviewed", 1), + command.replace("LC_ALL=C", "LC_ALL=C PYTHONPATH=/unreviewed", 1), + command.replace("/usr/bin/python3", "/usr/local/bin/python3", 1), + command.replace("-I -S -B", "-B -S -I", 1), + command + " extra.py", + command.replace("scripts/check.py", '"$HELPER"', 1), + command.replace("scripts/check.py", "./scripts/check.py", 1), + command.replace("scripts/check.py", "scripts/./check.py", 1), + command.replace("scripts/check.py", "scripts/../scripts/check.py", 1), + command.replace("/usr/bin/python3 -I -S -B", "/bin/sh -n", 1), + command.replace("/usr/bin/python3 -I -S -B", "/usr/bin/shellcheck", 1), + command.replace("/usr/bin/python3 -I -S -B", "/bin/sh -c", 1), + ] + for changed in mutations: + with self.subTest(command=changed): + result = self.run_policy( + f""" + jobs: + source: + steps: + - run: | + HELPER=scripts/check.py + {changed} + """, + {"scripts/check.py": "print('source check')", "extra.py": "pass"}, + ) + self.assertNotEqual(0, result.returncode) + + def test_isolated_source_model_refuses_symlink_target_outside_repository(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + base = Path(temporary_directory) + root = base / "repo" + workflow = root / ".github/workflows/check.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "jobs:\n source:\n steps:\n" + " - run: /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C " + "/usr/bin/python3 -I -S -B scripts/check.py\n" + ) + outside = base / "outside.py" + outside.write_text("print('outside')") + (root / "scripts").mkdir() + (root / "scripts/check.py").symlink_to(outside) + result = subprocess.run( + ["python3", str(POLICY), "--root", str(root)], + capture_output=True, text=True, check=False, + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("unclassified command /usr/bin/env", result.stderr) + + def test_isolated_diagnostics_refuse_extra_execution_and_substitution(self) -> None: + for command in ( + "/bin/sh -n docker/check.sh extra.sh", + "/usr/bin/shellcheck docker/check.sh extra.sh", + '/bin/sh -n "$(docker run -d --name shared image)"', + '/usr/bin/shellcheck "$(docker run -d --name shared image)"', + "/bin/sh -c 'sh docker/check.sh'", + ): + with self.subTest(command=command): + result = self.run_policy( + f""" + jobs: + source: + steps: + - run: /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C {command} + """, + {"docker/check.sh": "true", "extra.sh": "true"}, + ) + self.assertNotEqual(0, result.returncode) + def test_rejects_literal_helper_behind_unclassified_launcher(self) -> None: result = self.run_policy( """ diff --git a/tests/Unit/Support/php_apt_test.py b/tests/Unit/Support/php_apt_test.py new file mode 100644 index 000000000..f7e05eb72 --- /dev/null +++ b/tests/Unit/Support/php_apt_test.py @@ -0,0 +1,155 @@ +"""Inert first-RUN APT boundary controls; no actual APT or image execution.""" +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +APT = ROOT / "docker/apt" +SPEC = importlib.util.spec_from_file_location("php_apt_source", ROOT / "scripts/ci/verify-php-apt-source.py") +CHECK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK) + + +def execute_guard(*, version="3.0.4", library=None, status="install ok installed", + architecture="amd64", release='"13"', distro="debian", + client=None, apt_record=None, query_fails=False, compare_fails=False, args=()): + """Replace readbacks in a temporary source adapter, never in production.""" + if library is None: + library = version + if client is None: + client = f"apt {version} ({architecture})\nSupported modules: synthetic\n" + with tempfile.TemporaryDirectory(prefix="server-apt-source-") as temporary: + work = Path(temporary) + (work / "os-release").write_text(f"ID={distro}\nVERSION_ID={release}\n") + (work / "apt-record").write_text(apt_record if apt_record is not None else f"{status}\t{version}\n") + (work / "library-record").write_text(f"{status}\t{library}\n") + (work / "client-record").write_text(client) + query = work / "query" + query.write_text(f'''#!/bin/sh +/usr/bin/env > '{work}/environment' +{'exit 7' if query_fails else ':'} +case "$3" in + apt) /bin/cat '{work}/apt-record' ;; + libapt-pkg7.0) /bin/cat '{work}/library-record' ;; + *) exit 91 ;; +esac +''') + dpkg = work / "dpkg" + dpkg.write_text(f'''#!/bin/sh +case "$1" in + --print-architecture) printf '%s\\n' '{architecture}' ;; + --compare-versions) {'exit 8' if compare_fails else 'exec /usr/bin/dpkg "$@"'} ;; + *) exit 92 ;; +esac +''') + apt = work / "apt" + apt.write_text(f'''#!/bin/sh +printf '%s\\n' "$*" >> '{work}/apt-calls' +[ "$#" -eq 1 ] && [ "$1" = --version ] || exit 93 +/bin/cat '{work}/client-record' +''') + for path in (query, dpkg, apt): + path.chmod(0o700) + adapter = work / "guard.sh" + adapter.write_text((APT / "require-fixed-apt.sh").read_text() + .replace("/etc/os-release", str(work / "os-release")) + .replace("/usr/bin/dpkg-query", str(query)) + .replace("/usr/bin/dpkg", str(dpkg)) + .replace("/usr/bin/apt-get", str(apt))) + result = subprocess.run(["/usr/bin/env", "-i", "PATH=/usr/bin:/bin", "LC_ALL=C", "/bin/sh", + str(adapter), *args], capture_output=True, text=True, timeout=5, + env={**os.environ, "GH_TOKEN": "synthetic-not-a-credential", + "DPKG_ROOT": "/synthetic-override", "APT_CONFIG": "/synthetic-config"}) + environment = (work / "environment").read_text() if (work / "environment").exists() else "" + calls = (work / "apt-calls").read_text() if (work / "apt-calls").exists() else "" + return result, environment, calls + + +class PhpAptTest(unittest.TestCase): + def test_both_architectures_accept_exact_floor_and_later_fixed_pairs(self): + for architecture in ("amd64", "arm64"): + for version in ("3.0.4", "3.0.4+deb13u1", "3.0.5"): + with self.subTest(architecture=architecture, version=version): + result, _, calls = execute_guard(architecture=architecture, version=version) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"arch={architecture} apt={version}", result.stdout) + self.assertEqual(calls, "--version\n") + + def test_old_prerelease_mixed_malformed_and_unconfigured_records_refuse_before_client(self): + bad = [dict(version="3.0.3"), dict(version="3.0.4~rc1"), dict(library="3.0.3"), + dict(version=""), dict(version="3.0.4\ninstalled\t3.0.4"), + dict(status="deinstall ok config-files"), dict(status="install reinstreq installed"), + dict(apt_record="install ok installed\t3.0.4\ninstall ok installed\t3.0.4\n"), + dict(apt_record="3.0.4\n"), dict(query_fails=True), dict(compare_fails=True)] + for values in bad: + with self.subTest(values=values): + result, _, calls = execute_guard(**values) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, "") + + def test_unknown_duplicate_or_missing_distro_release_architecture_refuse(self): + for values in (dict(distro="ubuntu"), dict(distro="debian\nID=debian"), dict(distro=""), + dict(release='"12"'), dict(release='"13"\nVERSION_ID="13"'), + dict(architecture="i386"), dict(architecture="")): + with self.subTest(values=values): + result, _, calls = execute_guard(**values) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, "") + + def test_effective_client_must_match_the_installed_package_and_architecture(self): + for client in ("apt 3.0.3 (amd64)\n", "apt 3.0.4 (arm64)\n", "", " apt 3.0.4 (amd64)\n"): + with self.subTest(client=client): + result, _, _ = execute_guard(client=client) + self.assertNotEqual(result.returncode, 0) + + def test_no_caller_overrides_or_ambient_credentials_reach_readbacks(self): + result, _, calls = execute_guard(args=("--floor", "0")) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, "") + result, environment, _ = execute_guard() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertLessEqual({row.split("=", 1)[0] for row in environment.splitlines()}, {"PATH", "LC_ALL", "PWD"}) + + def test_source_binding_rejects_old_unproved_parent_inventory_and_guard(self): + source = (ROOT / "Dockerfile").read_text() + proof = (APT / "parent.v1.json").read_bytes() + guard = (APT / "require-fixed-apt.sh").read_bytes() + CHECK.require_source_boundary(source, proof, guard) + for changed in (source.replace(CHECK.INDEX, "sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef"), + source.replace(CHECK.INDEX, "sha256:" + "0" * 64)): + with self.assertRaisesRegex(ValueError, "unproved PHP parent"): + CHECK.require_source_boundary(changed, proof, guard) + value = json.loads(proof) + for key in ("child", "config", "spdx", "packages"): + changed = json.loads(proof) + changed["platforms"]["linux/amd64"][key] = "unproved" + with self.assertRaisesRegex(ValueError, "unreviewed parent inventory"): + CHECK.require_source_boundary(source, json.dumps(changed).encode(), guard) + self.assertEqual(set(value["platforms"]), {"linux/amd64", "linux/arm64"}) + with self.assertRaisesRegex(ValueError, "unreviewed effective-client guard"): + CHECK.require_source_boundary(source, proof, guard + b"\nexit 0\n") + + def test_first_run_stage_copy_and_inheritance_cannot_be_bypassed(self): + source = (ROOT / "Dockerfile").read_text() + proof = (APT / "parent.v1.json").read_bytes() + guard = (APT / "require-fixed-apt.sh").read_bytes() + for changed in (source.replace(CHECK.PREFIX + " \\\n && ", "", 1), + source.replace("RUN " + CHECK.PREFIX, "RUN apt-get update\nRUN " + CHECK.PREFIX, 1), + source.replace("FROM base AS production", "FROM scratch AS production"), + source.replace("COPY docker/apt/require-fixed-apt.sh " + CHECK.GUARD, ""), + source.replace("FROM base AS production", "FROM base AS production\nRUN apt-get update"), + source.replace("RUN " + CHECK.PREFIX, "run apt-get update\nRUN " + CHECK.PREFIX, 1), + source.replace("RUN " + CHECK.PREFIX, "COPY foreign " + CHECK.GUARD + "\nRUN " + CHECK.PREFIX, 1)): + with self.subTest(changed=changed[:60]), self.assertRaises(ValueError): + CHECK.require_source_boundary(changed, proof, guard) + + +if __name__ == "__main__": + unittest.main()