Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions scripts/ci/run-php-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
26 changes: 21 additions & 5 deletions scripts/ci/setup-php-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -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}"
93 changes: 93 additions & 0 deletions tests/ci/test_php_container_isolation.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion tests/ci/test_php_container_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading