diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh index 347f20e4..4d4013e6 100755 --- a/scripts/ci/run-php-container.sh +++ b/scripts/ci/run-php-container.sh @@ -15,10 +15,23 @@ options=(--rm --init --interactive --name "sdk-php-ci-${command}-${BASHPID}" --cpus 2 --memory 2g --pids-limit 512 --network host --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" --workdir "$PWD" - --mount "type=bind,source=$root,target=$root" - --mount "type=bind,source=$state,target=$state" + --mount "type=bind,source=$root,target=$root,readonly" + --mount "type=bind,source=$state,target=$state,readonly" + --mount "type=bind,source=$state/data,target=$state/data" + --mount "type=bind,source=$root/vendor,target=$root/vendor" + --mount "type=bind,source=$root/composer.lock,target=$root/composer.lock" + --mount "type=bind,source=$root/build,target=$root/build" + --mount "type=bind,source=$root/.phpunit.cache,target=$root/.phpunit.cache" --mount "type=bind,source=$root/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-sdk-php-ci.ini,readonly") +# Only the fixed Carbon 2 caller intentionally rewrites its root manifest. +# The host creates this marker before running any container; state stays RO. +if [[ -e "$state/writable-manifest" || -L "$state/writable-manifest" ]]; then + [[ -f "$state/writable-manifest" && ! -L "$state/writable-manifest" ]] + [[ "$(cat "$state/writable-manifest")" = carbon2 ]] + options+=(--mount "type=bind,source=$root/composer.json,target=$root/composer.json") +fi + # Composer's guard is a composite action outside the repository checkout. if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then options+=(--mount "type=bind,source=$GITHUB_ACTION_PATH,target=$GITHUB_ACTION_PATH,readonly") diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index ef6ab9ca..d3cef2d3 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -3,7 +3,9 @@ set -euo pipefail umask 077 root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -minor="${1:?Usage: setup-php-container.sh 8.1|8.2|8.4}" +minor="${1:?Usage: setup-php-container.sh 8.1|8.2|8.4 [carbon2]}" +mode="${2:-unit}" +case "$mode:$minor" in unit:8.1|unit:8.2|unit:8.4|carbon2:8.1) ;; *) echo 'Unsupported PHP CI caller mode.' >&2; exit 2 ;; esac image="$(python3 -I -S - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' import json,re,sys images=json.load(open(sys.argv[1])) @@ -45,17 +47,31 @@ docker run --rm --init --name "sdk-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local} printf 'php_ci_image=%s\n' "$runtime_id" state="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/sdk-php-ci.XXXXXX")" -mkdir -p "$state/bin" "$state/tmp" "$state/composer-home/cache" +# Repository controls and host wrappers stay read-only in every container. +# Only dependencies, generated locks, bounded test output and private data write. +for path in vendor composer.lock build .phpunit.cache composer.json; do + [[ ! -L "$root/$path" ]] +done +mkdir -p "$root/vendor" "$root/build" "$root/.phpunit.cache" "$state/bin" "$state/data/tmp" "$state/data/composer-home/cache" +if [[ ! -e "$root/composer.lock" ]]; then + printf '{}\n' > "$root/composer.lock" +fi +[[ -d "$root/vendor" && -d "$root/build" && -d "$root/.phpunit.cache" && -f "$root/composer.lock" && -f "$root/composer.json" ]] +if [[ "$mode" = carbon2 ]]; then + printf 'carbon2\n' > "$state/writable-manifest" +fi ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/php" ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/composer" +SDK_PHP_CI_TEST_IMAGE="$runtime_id" \ + python3 -I -S -B "$root/tests/ci/test_php_container_isolation.py" -v SDK_PHP_CI_TEST_IMAGE="$runtime_id" SDK_PHP_CI_ROOT="$root" SDK_PHP_CI_STATE="$state" \ python3 -I -S -B "$root/tests/ci/test_php_container_tls.py" -v { echo "SDK_PHP_CI_IMAGE=$runtime_id" echo "SDK_PHP_CI_ROOT=$root" echo "SDK_PHP_CI_STATE=$state" - echo "COMPOSER_HOME=$state/composer-home" - echo "COMPOSER_CACHE_DIR=$state/composer-home/cache" - echo "TMPDIR=$state/tmp" + echo "COMPOSER_HOME=$state/data/composer-home" + echo "COMPOSER_CACHE_DIR=$state/data/composer-home/cache" + echo "TMPDIR=$state/data/tmp" } >> "${GITHUB_ENV:?GITHUB_ENV is required}" echo "$state/bin" >> "${GITHUB_PATH:?GITHUB_PATH is required}" diff --git a/tests/ci/test_php_container_isolation.py b/tests/ci/test_php_container_isolation.py new file mode 100644 index 00000000..4549a304 --- /dev/null +++ b/tests/ci/test_php_container_isolation.py @@ -0,0 +1,93 @@ +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(os.environ.get("SDK_PHP_CI_TEST_IMAGE"), "requires the built PHP CI image") +class PhpContainerIsolationTests(unittest.TestCase): + def test_unit_controls_are_read_only(self): + self.check_boundary(False) + + def test_carbon_manifest_is_the_only_extra_writable_file(self): + self.check_boundary(True) + + def check_boundary(self, carbon): + # Use disposable controls so the predecessor can demonstrate its escape + # without changing the checkout or executing a substituted host wrapper. + with tempfile.TemporaryDirectory(prefix="sdk-php-isolation-") as directory: + fixture = Path(directory) + source = fixture / "source" + scripts = source / "scripts/ci" + scripts.mkdir(parents=True) + launcher = scripts / "run-php-container.sh" + shutil.copy2(ROOT / "scripts/ci/run-php-container.sh", launcher) + setup = scripts / "setup-php-container.sh" + shutil.copy2(ROOT / "scripts/ci/setup-php-container.sh", setup) + ini = source / "docker/php-ci/ci-defaults.ini" + ini.parent.mkdir(parents=True) + shutil.copy2(ROOT / "docker/php-ci/ci-defaults.ini", ini) + state = fixture / "state" + (state / "bin").mkdir(parents=True) + (state / "data/tmp").mkdir(parents=True) + for name in ("vendor", "build", ".phpunit.cache"): + (source / name).mkdir() + if carbon: + (state / "writable-manifest").write_text("carbon2\n") + (source / "composer.lock").write_text("{}\n") + (source / "composer.json").write_text('{"name":"sdk-php/isolation-control"}\n') + subprocess.run(["git", "init", "--quiet", str(source)], check=True, timeout=10) + for name in ("php", "composer"): + (state / "bin" / name).symlink_to(launcher) + expected = hashlib.sha256(launcher.read_bytes()).hexdigest() + environment = {name: os.environ[name] for name in ("PATH", "DOCKER_CONFIG") if name in os.environ} + environment.update({"SDK_PHP_CI_ROOT": str(source), "SDK_PHP_CI_STATE": str(state), + "SDK_PHP_CI_IMAGE": os.environ["SDK_PHP_CI_TEST_IMAGE"]}) + targets = [launcher, setup, state / "bin/php", state / "bin/composer", + source / "json.py", state / "bin/new-wrapper", state / "writable-manifest"] + if not carbon: + targets.append(source / "composer.json") + program = r''' + $targets = json_decode($argv[1], true, 512, JSON_THROW_ON_ERROR); + $data = $argv[2]; $results = []; + foreach ($targets as $target) { + $replacement = $data.'/replacement'; + file_put_contents($replacement, 'synthetic replacement'); + $results[] = [@file_put_contents($target, 'synthetic overwrite') !== false, + @rename($replacement, $target)]; + @unlink($replacement); + } + $outputs = [$data.'/canary', $argv[3].'/vendor/canary', $argv[3].'/composer.lock', + $argv[3].'/build/canary', $argv[3].'/.phpunit.cache/canary']; + if ($argv[4] === 'carbon2') { $outputs[] = $argv[3].'/composer.json'; } + foreach ($outputs as $output) { + if (file_put_contents($output, '{}') === false) { throw new RuntimeException('Writable output refused'); } + } + echo json_encode($results, JSON_THROW_ON_ERROR); + ''' + result = subprocess.run([str(state / "bin/php"), "-r", program, + json.dumps([str(path) for path in targets]), str(state / "data/tmp"), str(source), "carbon2" if carbon else "unit"], + cwd=source, env=environment, capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), [[False, False]] * len(targets), + "Container code replaced a host launcher or control path") + self.assertEqual(hashlib.sha256(launcher.read_bytes()).hexdigest(), expected) + for name in ("php", "composer"): + self.assertTrue((state / "bin" / name).is_symlink()) + self.assertEqual((state / "bin" / name).resolve(), launcher) + # Only invoke the next host command after verifying controls survived. + followup = subprocess.run([str(state / "bin/php"), "-r", 'echo "original-container-php";'], + cwd=source, env=environment, capture_output=True, text=True, timeout=30) + self.assertEqual(followup.returncode, 0, followup.stderr) + self.assertEqual(followup.stdout, "original-container-php") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_php_container_tls.py b/tests/ci/test_php_container_tls.py index aa422786..13ababfa 100644 --- a/tests/ci/test_php_container_tls.py +++ b/tests/ci/test_php_container_tls.py @@ -31,7 +31,7 @@ def do_GET(self): def log_message(self, *_arguments): pass - with tempfile.TemporaryDirectory(dir=state) as directory: + with tempfile.TemporaryDirectory(dir=state / "data") as directory: project = pathlib.Path(directory) def openssl(*arguments):