diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 1d6137d12..54d4d8569 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -126,7 +126,7 @@ jobs: - name: Prove Composer runs pinned or confined to a checked CA file env: DW_REQUIRE_COMPOSER_TRUST_TEST: '1' - run: python3 -m unittest tests/Unit/Support/composer_trust_test.py + run: python3 -m unittest tests/Unit/Support/composer_trust_test.py tests/Unit/Support/release_composer_audit_test.py - name: Prove the image builds, installs and checks the fixed Brotli decoder run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6011bef91..daa771a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,9 +98,7 @@ jobs: printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - name: Audit immutable PHP release dependencies - # In the pinned Composer image, whose CA store is checked (GHSA-6q48-6c7w-9gw2). - run: >- - docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD/release-source:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction + run: sh scripts/ci/audit-immutable-release.sh release-source - name: Validate release publish context and credentials id: release_publish diff --git a/scripts/ci/audit-immutable-release-container.sh b/scripts/ci/audit-immutable-release-container.sh new file mode 100644 index 000000000..b6f91b4e4 --- /dev/null +++ b/scripts/ci/audit-immutable-release-container.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Runs only in the pinned disposable Composer image. Establish and verify the +# decoder floor before Composer or curl can decode any network response. +set -eu + +identity=${1:?The nonprivileged audit UID:GID is required} +case "$identity" in + *[!0-9:]*|:*|*:|*:*:*) echo 'Audit identity must be a nonprivileged UID:GID.' >&2; exit 1 ;; + *:*) ;; + *) echo 'Audit identity must contain both UID and GID.' >&2; exit 1 ;; +esac +test "${identity%%:*}" -gt 0 +test "${identity#*:}" -gt 0 +: "${COMPOSER_CAFILE:?A checked CA file is required}" +: "${COMPOSER_CAPATH:?An empty CA directory is required}" +test -s "$COMPOSER_CAFILE" +test -d "$COMPOSER_CAPATH" +test -z "$(ls -A "$COMPOSER_CAPATH")" + +apk add --no-cache --upgrade 'brotli-libs>=1.2.1-r0' su-exec +apk info -e 'brotli-libs>=1.2.1-r0' >/dev/null + +export COMPOSER_HOME=/tmp/release-composer +exec su-exec "$identity" composer --no-plugins --no-scripts audit --locked --no-dev --no-interaction diff --git a/scripts/ci/audit-immutable-release.sh b/scripts/ci/audit-immutable-release.sh new file mode 100644 index 000000000..3a52287c4 --- /dev/null +++ b/scripts/ci/audit-immutable-release.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Audit an immutable source lock in a disposable, decoder-patched Composer +# image. Source stays read-only; the audit receives no publishing credentials. +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +source=$(realpath -- "${1:?Usage: audit-immutable-release.sh SOURCE_DIRECTORY}") +image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee +trust=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-audit.XXXXXXXX") +trap 'rm -rf "$trust"' EXIT HUP INT TERM + +sh "$here/checked-ca.sh" "$trust" + +docker run --rm --user 0:0 --entrypoint /bin/sh \ + --mount "type=bind,src=$source,dst=/app,readonly" \ + --mount "type=bind,src=$here,dst=/release-tools,readonly" \ + --mount "type=bind,src=$trust,dst=/release-trust,readonly" \ + -e COMPOSER_CAFILE=/release-trust/ca.pem \ + -e COMPOSER_CAPATH=/release-trust/no-ca-directory \ + -e SSL_CERT_FILE=/release-trust/ca.pem \ + -e SSL_CERT_DIR=/release-trust/no-ca-directory \ + -w /app "$image" /release-tools/audit-immutable-release-container.sh "$(id -u):$(id -g)" diff --git a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php index 0e8d80d79..52d5b264c 100644 --- a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php +++ b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php @@ -113,7 +113,7 @@ public function test_php_security_audits_fail_before_release_publication(): void $this->assertIsInt($loginIndex); $this->assertLessThan($loginIndex, $auditIndex); $this->assertSame( - 'docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD/release-source:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction', + 'sh scripts/ci/audit-immutable-release.sh release-source', $steps[$auditIndex]['run'], ); $this->assertArrayNotHasKey('continue-on-error', $steps[$auditIndex]); diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 787c0f989..be8fe726c 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -61,19 +61,6 @@ } ] }, - { - "path": ".github/workflows/release.yml", - "line": "docker run --rm --user \"$(id -u):$(id -g)\" -e COMPOSER_HOME=/tmp/composer -v \"$PWD/release-source:/app:ro\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction", - "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", - "guards": [ - { - "type": "pinned-reference", - "line": null, - "distance": null - } - ] - }, { "path": ".github/workflows/server-perf.yml", "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", @@ -1321,6 +1308,32 @@ "distance": 4 } ] + }, + { + "path": "scripts/ci/audit-immutable-release.sh", + "line": "image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer digest; checked-ca.sh provides the validated file and empty directory. Inner helper installs and verifies the decoder floor before requests.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, + { + "path": "scripts/ci/audit-immutable-release-container.sh", + "line": "exec su-exec \"$identity\" composer --no-plugins --no-scripts audit --locked --no-dev --no-interaction", + "count": 1, + "confinement": "Outer immutable-release helper binds the checked CA pair. The inner helper verifies brotli-libs>=1.2.1-r0 before the nonprivileged, plugins-disabled audit; release_composer_audit_test.py executes refusal and ordering controls.", + "guards": [ + { + "type": "checked-ca", + "line": "test -s \"$COMPOSER_CAFILE\"", + "distance": 8 + } + ] } ] } diff --git a/tests/Unit/Support/release_composer_audit_test.py b/tests/Unit/Support/release_composer_audit_test.py new file mode 100644 index 000000000..635b443ec --- /dev/null +++ b/tests/Unit/Support/release_composer_audit_test.py @@ -0,0 +1,172 @@ +"""The actual release-audit shell must refuse before making an unsafe request.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +AUDIT = ROOT / "scripts/ci/audit-immutable-release-container.sh" +WRAPPER = ROOT / "scripts/ci/audit-immutable-release.sh" + + +class ReleaseComposerAuditTest(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory(prefix="dw-release-audit-test-") + self.addCleanup(temporary.cleanup) + self.work = Path(temporary.name) + self.bin = self.work / "bin" + self.bin.mkdir() + self.log = self.work / "commands.jsonl" + self.ca = self.work / "ca.pem" + self.ca.write_text("constructed checked bundle\n") + self.capath = self.work / "empty-ca" + self.capath.mkdir() + self.env = { + **os.environ, + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "AUDIT_TEST_LOG": str(self.log), + "COMPOSER_CAFILE": str(self.ca), + "COMPOSER_CAPATH": str(self.capath), + } + self.executable("apk", ''' +import json, os, sys +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["apk", *sys.argv[1:]]) + "\\n") +raise SystemExit(int(os.environ.get("APK_ADD_STATUS" if sys.argv[1] == "add" else "APK_INFO_STATUS", "0"))) +''') + self.executable("su-exec", ''' +import json, os, sys +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["su-exec", *sys.argv[1:]]) + "\\n") +os.execvp(sys.argv[2], sys.argv[2:]) +''') + self.executable("composer", ''' +import json, os, sys +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["composer", *sys.argv[1:]]) + "\\n") +raise SystemExit(int(os.environ.get("COMPOSER_STATUS", "0"))) +''') + + def executable(self, name: str, body: str) -> None: + path = self.bin / name + path.write_text("#!/usr/bin/env python3\n" + body.lstrip()) + path.chmod(0o755) + + def run_audit(self, source: str | None = None, identity: str = "1000:1000") -> subprocess.CompletedProcess[str]: + script = AUDIT + if source is not None: + script = self.work / "mutated-audit.sh" + script.write_text(source) + return subprocess.run(["sh", str(script), identity], env=self.env, capture_output=True, text=True) + + def commands(self) -> list[list[str]]: + return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + + def assert_safe_order(self, result: subprocess.CompletedProcess[str]) -> None: + self.assertEqual(0, result.returncode, result.stderr) + commands = self.commands() + self.assertEqual(["apk", "apk", "su-exec", "composer"], [command[0] for command in commands]) + self.assertEqual(["apk", "add", "--no-cache", "--upgrade", "brotli-libs>=1.2.1-r0", "su-exec"], commands[0]) + self.assertEqual(["apk", "info", "-e", "brotli-libs>=1.2.1-r0"], commands[1]) + self.assertEqual("1000:1000", commands[2][1]) + self.assertEqual(["composer", "--no-plugins", "--no-scripts", "audit", "--locked", "--no-dev", "--no-interaction"], commands[3]) + + def test_installs_and_verifies_floor_before_nonprivileged_locked_audit(self) -> None: + self.assert_safe_order(self.run_audit()) + + def test_failed_upgrade_cannot_reach_composer(self) -> None: + self.env["APK_ADD_STATUS"] = "23" + self.assertEqual(23, self.run_audit().returncode) + self.assertEqual(["apk"], [command[0] for command in self.commands()]) + + def test_unmet_floor_cannot_reach_composer(self) -> None: + self.env["APK_INFO_STATUS"] = "24" + self.assertEqual(24, self.run_audit().returncode) + self.assertEqual(["apk", "apk"], [command[0] for command in self.commands()]) + + def test_audit_failure_keeps_its_exit_code(self) -> None: + self.env["COMPOSER_STATUS"] = "7" + self.assertEqual(7, self.run_audit().returncode) + self.assertEqual("composer", self.commands()[-1][0]) + + def test_root_or_malformed_identity_refuses_before_download(self) -> None: + for identity in ["0:0", "0:1000", "1000:0", "1000", "1000:1000:1", "1000:abc", "-1:1000"]: + with self.subTest(identity): + self.log.unlink(missing_ok=True) + self.assertNotEqual(0, self.run_audit(identity=identity).returncode) + self.assertEqual([], self.commands()) + + def test_missing_or_nonempty_ca_pair_refuses_before_package_download(self) -> None: + self.env.pop("COMPOSER_CAFILE") + self.assertNotEqual(0, self.run_audit().returncode) + self.assertEqual([], self.commands()) + self.env["COMPOSER_CAFILE"] = str(self.ca) + (self.capath / "extra.pem").write_text("unexpected root") + self.assertNotEqual(0, self.run_audit().returncode) + self.assertEqual([], self.commands()) + + def test_removing_or_moving_the_floor_is_caught(self) -> None: + source = AUDIT.read_text() + floor = "apk info -e 'brotli-libs>=1.2.1-r0' >/dev/null\n" + self.assertIn(floor, source) + for mutated in [source.replace(floor, ""), source.replace(floor, "") + floor]: + with self.subTest(source=mutated): + self.log.unlink(missing_ok=True) + result = self.run_audit(mutated) + with self.assertRaises(AssertionError): + self.assert_safe_order(result) + + def prepare_wrapper(self) -> None: + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", str(self.work / "test.key"), "-out", str(self.ca), + "-subj", "/CN=release-audit-constructed-ca", "-days", "1", + ], check=True, capture_output=True) + self.env["AUDIT_TEST_CA"] = str(self.ca) + self.env["RUNNER_TEMP"] = str(self.work) + self.executable("docker", ''' +import json, os, pathlib, sys +args = sys.argv[1:] +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["docker", *args]) + "\\n") +if "cat" in args: + print(pathlib.Path(os.environ["AUDIT_TEST_CA"]).read_text(), end="") +raise SystemExit(0) +''') + + def test_wrapper_checks_bundle_and_binds_source_and_trust_read_only(self) -> None: + self.prepare_wrapper() + result = subprocess.run(["sh", str(WRAPPER), str(self.work)], env=self.env, capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + commands = self.commands() + self.assertEqual(2, len(commands)) + self.assertIn("none", commands[0]) + args = commands[1] + self.assertIn("COMPOSER_CAFILE=/release-trust/ca.pem", args) + self.assertIn("COMPOSER_CAPATH=/release-trust/no-ca-directory", args) + self.assertIn("SSL_CERT_FILE=/release-trust/ca.pem", args) + self.assertIn("SSL_CERT_DIR=/release-trust/no-ca-directory", args) + mounts = [args[index + 1] for index, value in enumerate(args) if value == "--mount"] + self.assertEqual(3, len(mounts)) + self.assertTrue(all(mount.endswith(",readonly") for mount in mounts)) + trust = next(mount for mount in mounts if ",dst=/release-trust," in mount) + directory = trust.split("src=", 1)[1].split(",", 1)[0] + self.assertFalse(Path(directory).exists(), "the disposable CA directory must be retired") + self.assertEqual([], list(self.work.glob("release-audit.*"))) + + def test_bad_bundle_refuses_before_starting_networked_audit(self) -> None: + self.prepare_wrapper() + self.ca.write_text("not a certificate bundle\n") + result = subprocess.run(["sh", str(WRAPPER), str(self.work)], env=self.env, capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertEqual(1, len(self.commands())) + self.assertEqual([], list(self.work.glob("release-audit.*"))) + + +if __name__ == "__main__": + unittest.main()