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
2 changes: 1 addition & 1 deletion .github/workflows/phpunit-feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions scripts/ci/audit-immutable-release-container.sh
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions scripts/ci/audit-immutable-release.sh
Original file line number Diff line number Diff line change
@@ -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)"
2 changes: 1 addition & 1 deletion tests/Unit/ReleaseImagePublishWorkflowContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
39 changes: 26 additions & 13 deletions tests/Unit/Support/composer-sinks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\",
Expand Down Expand Up @@ -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
}
]
}
]
}
172 changes: 172 additions & 0 deletions tests/Unit/Support/release_composer_audit_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading