diff --git a/.github/actions/bootstrap-patched-pip/action.yml b/.github/actions/bootstrap-patched-pip/action.yml index 3b75fe8..4d19e28 100644 --- a/.github/actions/bootstrap-patched-pip/action.yml +++ b/.github/actions/bootstrap-patched-pip/action.yml @@ -37,6 +37,10 @@ outputs: runs: using: composite steps: + - name: Protect the hosted toolcache parent + if: runner.environment == 'github-hosted' && runner.os == 'Linux' + shell: bash + run: /usr/bin/sudo --non-interactive /usr/bin/python3 -I -S -B "$GITHUB_ACTION_PATH/protect-hosted-runtime.py" "$pythonLocation" - name: Seed or verify the exact job-private pip runtime id: runtime shell: bash diff --git a/.github/actions/bootstrap-patched-pip/pip_runtime.py b/.github/actions/bootstrap-patched-pip/pip_runtime.py index 883a9a5..5165569 100644 --- a/.github/actions/bootstrap-patched-pip/pip_runtime.py +++ b/.github/actions/bootstrap-patched-pip/pip_runtime.py @@ -639,12 +639,19 @@ def _seed_runtime(root, interpreter, manager, *, expected_identity, execute): sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.exit(main()) ''' +SCRIPT_BODY_PIP_26 = '''import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = sys.argv[0].removesuffix('.exe') + sys.exit(main()) +''' def _entrypoint(path, private_python): with held_file(path, maximum=8192, owned=True, executable=True) as (contents, identity): header = ("#!" + str(private_python) + "\n").encode() - expected = {header + source.encode() for source in (SCRIPT_BODY_OLD, SCRIPT_BODY_CURRENT)} + expected = {header + source.encode() + for source in (SCRIPT_BODY_OLD, SCRIPT_BODY_CURRENT, SCRIPT_BODY_PIP_26)} if contents not in expected: raise ValueError("Private pip entrypoint is not the bound stock script/shebang") return contents, identity diff --git a/.github/actions/bootstrap-patched-pip/protect-hosted-runtime.py b/.github/actions/bootstrap-patched-pip/protect-hosted-runtime.py new file mode 100644 index 0000000..17e9731 --- /dev/null +++ b/.github/actions/bootstrap-patched-pip/protect-hosted-runtime.py @@ -0,0 +1,117 @@ +"""Protect the shared parent of the selected GitHub-hosted Python runtime.""" + +import os +import re +import stat +import sys + + +def protect_directory(path): + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + protect_parent(descriptor, 0) + finally: + os.close(descriptor) + + +def protect_parent(descriptor, runner_uid): + before = os.fstat(descriptor) + mode = stat.S_IMODE(before.st_mode) + if before.st_uid not in {0, runner_uid} or mode & (stat.S_ISUID | stat.S_ISGID): + raise ValueError("Hosted runtime parent must be root-owned or runner-owned without special modes") + expected = ((mode | stat.S_ISVTX) if before.st_uid == 0 and mode & 0o022 + else mode & ~0o022) + if mode != expected: + os.fchmod(descriptor, expected) + after = os.fstat(descriptor) + if ((before.st_dev, before.st_ino, before.st_uid) + != (after.st_dev, after.st_ino, after.st_uid) + or stat.S_IMODE(after.st_mode) != expected): + raise ValueError("Hosted runtime parent protection was not established") + + +def seal_tree(descriptor, runner_uid, budget=None, depth=0): + """Remove other-user writes from only the selected runtime, without following links.""" + budget = [0] if budget is None else budget + if depth > 64: + raise ValueError("Selected runtime exceeds the metadata depth limit") + metadata = os.fstat(descriptor) + if metadata.st_uid not in {0, runner_uid} or metadata.st_mode & (stat.S_ISUID | stat.S_ISGID): + raise ValueError("Selected runtime has an unsupported owner or special mode") + os.fchmod(descriptor, stat.S_IMODE(metadata.st_mode) & ~0o022) + for name in os.listdir(descriptor): + budget[0] += 1 + if budget[0] > 200000: + raise ValueError("Selected runtime exceeds the metadata walk limit") + seal_entry(descriptor, name, runner_uid, budget, depth) + after = os.fstat(descriptor) + if ((metadata.st_dev, metadata.st_ino, metadata.st_uid) + != (after.st_dev, after.st_ino, after.st_uid) + or stat.S_IMODE(after.st_mode) != stat.S_IMODE(metadata.st_mode) & ~0o022): + raise ValueError("Selected runtime directory protection was not established") + + +def seal_entry(descriptor, name, runner_uid, budget, depth): + entry = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + if entry.st_uid not in {0, runner_uid}: + raise ValueError("Selected runtime entry has an unsupported owner") + if stat.S_ISLNK(entry.st_mode): + return + directory = stat.S_ISDIR(entry.st_mode) + if not directory and (not stat.S_ISREG(entry.st_mode) or entry.st_nlink != 1): + raise ValueError("Selected runtime entry is not a private regular file or directory") + child = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + | (os.O_DIRECTORY if directory else 0), dir_fd=descriptor) + try: + held = os.fstat(child) + if ((held.st_dev, held.st_ino, held.st_uid, held.st_mode, held.st_nlink) + != (entry.st_dev, entry.st_ino, entry.st_uid, entry.st_mode, entry.st_nlink)): + raise ValueError("Selected runtime entry changed during protection") + if directory: + seal_tree(child, runner_uid, budget, depth + 1) + else: + seal_file(child, held) + finally: + os.close(child) + + +def seal_file(descriptor, before): + if before.st_mode & (stat.S_ISUID | stat.S_ISGID): + raise ValueError("Selected runtime file has a special mode") + expected = stat.S_IMODE(before.st_mode) & ~0o022 + os.fchmod(descriptor, expected) + after = os.fstat(descriptor) + if ((before.st_dev, before.st_ino, before.st_uid, before.st_nlink) + != (after.st_dev, after.st_ino, after.st_uid, after.st_nlink) + or stat.S_IMODE(after.st_mode) != expected): + raise ValueError("Selected runtime file protection was not established") + + +def protect_hosted_python(location, runner_uid): + if re.fullmatch(r"/opt/hostedtoolcache/Python/\d+\.\d+\.\d+/(?:x64|arm64)", location) is None: + raise ValueError("Selected runtime is outside the fixed hosted Python location") + # Hold each ancestor and never follow a directory symlink. Root-owned + # shared parents get sticky ownership; runner-owned parents lose other-user + # writes. Other versions and languages are never traversed. + descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + for part in location.split("/")[1:]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + protect_parent(descriptor, runner_uid) + seal_tree(descriptor, runner_uid) + finally: + os.close(descriptor) + + +def main(): + if (len(sys.argv) != 2 or os.geteuid() != 0 + or re.fullmatch(r"[1-9]\d*", os.environ.get("SUDO_UID", "")) is None): + raise SystemExit("Hosted runtime parent protection requires its fixed privileged invocation") + protect_hosted_python(sys.argv[1], int(os.environ["SUDO_UID"])) + print("Hosted runtime parent protection verified.") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/hosted-pip.yml b/.github/workflows/hosted-pip.yml new file mode 100644 index 0000000..bd62022 --- /dev/null +++ b/.github/workflows/hosted-pip.yml @@ -0,0 +1,42 @@ +name: Hosted pip bootstrap + +on: + push: + branches: [main] + paths: + - '.github/actions/bootstrap-patched-pip/action.yml' + - '.github/actions/bootstrap-patched-pip/pip_runtime.py' + - '.github/actions/bootstrap-patched-pip/export-trust.py' + - '.github/actions/bootstrap-patched-pip/protect-hosted-runtime.py' + - '.github/workflows/hosted-pip.yml' + pull_request: + paths: + - '.github/actions/bootstrap-patched-pip/action.yml' + - '.github/actions/bootstrap-patched-pip/pip_runtime.py' + - '.github/actions/bootstrap-patched-pip/export-trust.py' + - '.github/actions/bootstrap-patched-pip/protect-hosted-runtime.py' + - '.github/workflows/hosted-pip.yml' + +permissions: + contents: read + +concurrency: + group: hosted-pip-${{ github.ref }} + cancel-in-progress: true + +jobs: + bootstrap: + name: Bootstrap on hosted Python 3.13 + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - uses: ./.github/actions/bootstrap-patched-pip # local + with: + clients: pip,python + - run: python -I -m pip --version diff --git a/qualification/policy.json b/qualification/policy.json index 13d814f..a05b6a6 100644 --- a/qualification/policy.json +++ b/qualification/policy.json @@ -63,7 +63,8 @@ "docker/login-action": { "af1e73f918a031802d376d3c8bbc3fe56130a9b0": "v4", "dbcb813823bdd20940b903addbd779551569679f": "v4.6.0", - "81c6f93d4bc991e510b342a00b419b3bb4efc5c6": "v4.7.0" + "81c6f93d4bc991e510b342a00b419b3bb4efc5c6": "v4.7.0", + "8aa5831fb8d48d8f76594c7ddde61ae58019f550": "v4.8.0" }, "docker/metadata-action": { "dc802804100637a589fabce1cb79ff13a1411302": "v6" @@ -83,6 +84,7 @@ "durable-workflow/.github": { "18783be6bef737cc778e4c7282a8797e39daa61a": "fixed-apt", "242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5": "clean-trust", + "52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2": "clean-trust", "54cce4c6a814c8137dafbee594a1e0bdcf0ba60c": "clean-trust", "699d09d602fc0353160bdecaa2b23f07ef4c7c21": "clean-trust", "75587fc33ff40c34f32ef777293dad56bb6f48c7": "clean-trust", @@ -115,7 +117,8 @@ }, "sonarsource/sonarqube-scan-action": { "d209202bc7d53ff1cc128f7f907dac145c9d6ae9": "v8.3.0", - "4d1dc0c91ee400dcf8a6dddf2f705ac36542e304": "v8.4.0" + "4d1dc0c91ee400dcf8a6dddf2f705ac36542e304": "v8.4.0", + "e805d875275f728fac1885b413934be6d7c63f65": "v8.4.1" } }, "supported_javascript_runtimes": [ diff --git a/tests/test_ci_trust.py b/tests/test_ci_trust.py index 750fb7b..c7e6eaf 100644 --- a/tests/test_ci_trust.py +++ b/tests/test_ci_trust.py @@ -18,7 +18,8 @@ test_php_linux_recipe, test_php_linux_archive, test_setup_php_linux, test_php_linux_qualification, test_php_linux_qualification_workflow, test_bookworm_apt_source, test_private_pip_runtime, - test_php_linux_normalize, test_php_linux_archive_metadata, test_php_linux_alternatives) + test_php_linux_normalize, test_php_linux_archive_metadata, test_php_linux_alternatives, + test_hosted_runtime_parent) ROOT = Path(__file__).resolve().parents[1] @@ -37,7 +38,7 @@ def load_tests(loader, standard_tests, pattern): for module in (test_fixed_apt, test_php_linux_bootstrap, test_php_linux_tools, test_php_linux_recipe, test_php_linux_archive, test_setup_php_linux, test_php_linux_qualification, test_php_linux_qualification_workflow, - test_bookworm_apt_source, test_private_pip_runtime, + test_bookworm_apt_source, test_private_pip_runtime, test_hosted_runtime_parent, test_php_linux_normalize, test_php_linux_archive_metadata, test_php_linux_alternatives): standard_tests.addTests(loader.loadTestsFromModule(module)) return standard_tests diff --git a/tests/test_hosted_runtime_parent.py b/tests/test_hosted_runtime_parent.py new file mode 100644 index 0000000..d1165f9 --- /dev/null +++ b/tests/test_hosted_runtime_parent.py @@ -0,0 +1,247 @@ +"""Controls for the fixed hosted /opt repair; never change the host's /opt.""" + +import importlib.util +import os +import io +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest.mock import patch + +from tests.test_private_pip_runtime import runtime + + +SOURCE = (Path(__file__).resolve().parents[1] + / ".github/actions/bootstrap-patched-pip/protect-hosted-runtime.py") +SPEC = importlib.util.spec_from_file_location("protect_hosted_runtime", SOURCE) +protection = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(protection) + + +class HostedRuntimeParentTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) / "opt" + self.directory.mkdir() + self.original_fstat = os.fstat + self.original_stat = os.stat + self.original_open = os.open + self.runner_uid = os.getuid() + self.identity = self.directory.stat() + + def root_metadata(self, metadata): + if (metadata.st_dev, metadata.st_ino) == (self.identity.st_dev, self.identity.st_ino): + fields = list(metadata) + fields[4] = 0 + return os.stat_result(fields) + return metadata + + def root_owned(self, descriptor): + return self.root_metadata(self.original_fstat(descriptor)) + + def root_stat(self, *arguments, **options): + return self.root_metadata(self.original_stat(*arguments, **options)) + + def test_writable_parent_is_refused_before_and_accepted_only_after_sticky_protection(self): + self.directory.chmod(0o777) + with patch.object(os, "fstat", side_effect=self.root_owned), \ + patch.object(os, "stat", side_effect=self.root_stat): + with self.assertRaisesRegex(ValueError, "Runtime directory refused"): + with runtime.BoundDirectory(self.directory): + pass + protection.protect_directory(self.directory) + self.assertEqual(0o1777, stat.S_IMODE(self.directory.stat().st_mode)) + with runtime.BoundDirectory(self.directory): + pass + + def test_protected_directory_permissions_are_preserved_without_a_write(self): + for mode in (0o755, 0o1777): + with self.subTest(mode=mode): + self.directory.chmod(mode) + with patch.object(os, "fstat", side_effect=self.root_owned), \ + patch.object(os, "fchmod") as change: + protection.protect_directory(self.directory) + change.assert_not_called() + self.assertEqual(mode, stat.S_IMODE(self.directory.stat().st_mode)) + + def test_foreign_owner_and_special_modes_refuse_without_mutation(self): + self.directory.chmod(0o777) + with patch.object(os, "fchmod") as change: + with self.assertRaisesRegex(ValueError, "root-owned"): + protection.protect_directory(self.directory) + change.assert_not_called() + self.directory.chmod(0o2777) + with patch.object(os, "fstat", side_effect=self.root_owned), \ + patch.object(os, "fchmod") as change: + with self.assertRaisesRegex(ValueError, "root-owned"): + protection.protect_directory(self.directory) + change.assert_not_called() + + def test_link_and_regular_file_refuse_without_following_or_mutation(self): + link = self.directory.parent / "link" + link.symlink_to(self.directory) + file = self.directory.parent / "file" + file.touch() + for path in (link, file): + with self.subTest(path=path.name), patch.object(os, "fchmod") as change: + with self.assertRaises(OSError): + protection.protect_directory(path) + change.assert_not_called() + + def test_failed_or_ineffective_permission_change_cannot_pass(self): + self.directory.chmod(0o777) + with patch.object(os, "fstat", side_effect=self.root_owned): + with patch.object(os, "fchmod", side_effect=PermissionError): + with self.assertRaises(PermissionError): + protection.protect_directory(self.directory) + with patch.object(os, "fchmod"): + with self.assertRaisesRegex(ValueError, "not established"): + protection.protect_directory(self.directory) + with self.assertRaisesRegex(ValueError, "Runtime directory refused"): + with runtime.BoundDirectory(self.directory): + pass + + def test_selected_tree_preserves_contents_and_execute_bits_but_closes_writable_entries(self): + library = self.directory / "lib" + library.mkdir() + binary = library / "python" + binary.write_bytes(b"inert selected-runtime fixture") + binary.chmod(0o777) + library.chmod(0o777) + self.directory.chmod(0o777) + other = self.directory.parent / "other-version" + other.mkdir() + other.chmod(0o777) + link = library / "outside" + link.symlink_to(other, target_is_directory=True) + descriptor = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY) + try: + protection.seal_tree(descriptor, os.getuid()) + finally: + os.close(descriptor) + self.assertEqual(b"inert selected-runtime fixture", binary.read_bytes()) + for path in (self.directory, library, binary): + self.assertEqual(0o755, stat.S_IMODE(path.stat().st_mode)) + self.assertTrue(link.is_symlink()) + self.assertEqual(0o777, stat.S_IMODE(other.stat().st_mode)) + + def test_hardlinks_and_special_files_refuse_without_modifying_outside_contents(self): + outside = self.directory.parent / "outside" + outside.write_bytes(b"outside") + outside.chmod(0o666) + os.link(outside, self.directory / "hardlink") + descriptor = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY) + try: + with self.assertRaisesRegex(ValueError, "private regular"): + protection.seal_tree(descriptor, self.runner_uid) + finally: + os.close(descriptor) + self.assertEqual(0o666, stat.S_IMODE(outside.stat().st_mode)) + (self.directory / "hardlink").unlink() + os.mkfifo(self.directory / "fifo") + descriptor = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY) + try: + with self.assertRaisesRegex(ValueError, "private regular"): + protection.seal_tree(descriptor, self.runner_uid) + finally: + os.close(descriptor) + + def test_noncanonical_hosted_location_refuses_before_opening_any_path(self): + for location in ("/tmp/python", "/opt/hostedtoolcache/Python/3.13.18/../../other", + "/opt/hostedtoolcache/Python/3.13.18/x86", "//opt/hostedtoolcache/Python/3.13.18/x64"): + with self.subTest(location=location), patch.object(os, "open") as opened: + with self.assertRaisesRegex(ValueError, "fixed hosted"): + protection.protect_hosted_python(location, self.runner_uid) + opened.assert_not_called() + + def sandbox_open(self, path, flags, **options): + if path == "/" and "dir_fd" not in options: + path = self.directory.parent + return self.original_open(path, flags, **options) + + def hosted_tree(self): + selected = self.directory / "hostedtoolcache/Python/3.13.18/x64" + selected.mkdir(parents=True) + file = selected / "python" + file.write_bytes(b"selected interpreter fixture") + file.chmod(0o777) + for directory in (selected, *selected.parents): + if directory == self.directory.parent: + break + directory.chmod(0o777) + return selected, file + + def test_complete_hosted_ancestry_and_selected_tree_are_protected_without_touching_other_versions(self): + selected, file = self.hosted_tree() + other = selected.parent / "arm64" + other.mkdir(mode=0o777) + other.chmod(0o777) + with patch.object(os, "open", side_effect=self.sandbox_open): + protection.protect_hosted_python("/opt/hostedtoolcache/Python/3.13.18/x64", self.runner_uid) + self.assertEqual(b"selected interpreter fixture", file.read_bytes()) + for directory in (selected, *selected.parents): + if directory == self.directory.parent: + break + self.assertEqual(0o755, stat.S_IMODE(directory.stat().st_mode)) + self.assertEqual(0o755, stat.S_IMODE(file.stat().st_mode)) + self.assertEqual(0o777, stat.S_IMODE(other.stat().st_mode)) + + def test_linked_ancestry_refuses_before_traversing_selected_runtime(self): + selected, file = self.hosted_tree() + python = selected.parent.parent + moved = python.with_name("other-python") + python.rename(moved) + python.symlink_to(moved, target_is_directory=True) + with patch.object(os, "open", side_effect=self.sandbox_open): + with self.assertRaises(OSError): + protection.protect_hosted_python("/opt/hostedtoolcache/Python/3.13.18/x64", self.runner_uid) + self.assertEqual(0o777, stat.S_IMODE(file.stat().st_mode)) + + def test_metadata_limits_refuse_without_traversing_a_large_or_deep_tree(self): + (self.directory / "file").touch() + descriptor = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY) + try: + with self.assertRaisesRegex(ValueError, "walk limit"): + protection.seal_tree(descriptor, self.runner_uid, [200000]) + with self.assertRaisesRegex(ValueError, "depth limit"): + protection.seal_tree(descriptor, self.runner_uid, depth=65) + finally: + os.close(descriptor) + + def test_special_mode_and_ineffective_file_protection_refuse(self): + file = self.directory / "file" + file.touch() + descriptor = os.open(file, os.O_RDONLY) + try: + file.chmod(0o4777) + special = os.fstat(descriptor) + with self.assertRaisesRegex(ValueError, "special mode"): + protection.seal_file(descriptor, special) + file.chmod(0o777) + before = os.fstat(descriptor) + with patch.object(os, "fchmod"): + with self.assertRaisesRegex(ValueError, "not established"): + protection.seal_file(descriptor, before) + finally: + os.close(descriptor) + + def test_cli_requires_sudo_identity_and_passes_only_the_selected_location(self): + location = "/opt/hostedtoolcache/Python/3.13.18/x64" + for arguments, uid, sudo_uid in ((["tool"], 0, "1000"), (["tool", location], 1000, "1000"), + (["tool", location], 0, ""), (["tool", location], 0, "0")): + with self.subTest(arguments=arguments, uid=uid, sudo_uid=sudo_uid), \ + patch.object(sys, "argv", arguments), patch.object(os, "geteuid", return_value=uid), \ + patch.dict(os.environ, {"SUDO_UID": sudo_uid}), \ + patch.object(protection, "protect_hosted_python") as protect: + with self.assertRaises(SystemExit): + protection.main() + protect.assert_not_called() + with patch.object(sys, "argv", ["tool", location]), patch.object(os, "geteuid", return_value=0), \ + patch.dict(os.environ, {"SUDO_UID": "1000"}), \ + patch.object(protection, "protect_hosted_python") as protect, \ + patch("sys.stdout", new_callable=io.StringIO): + protection.main() + protect.assert_called_once_with(location, 1000) diff --git a/tests/test_private_pip_runtime.py b/tests/test_private_pip_runtime.py index 382a678..16d54b3 100644 --- a/tests/test_private_pip_runtime.py +++ b/tests/test_private_pip_runtime.py @@ -60,6 +60,24 @@ def setUp(self): self.environment.start() self.addCleanup(self.environment.stop) + def test_pinned_pip26_generated_script_binds_the_exact_interpreter_and_complete_body(self): + observed_body = b"import sys\nfrom pip._internal.cli.main import main\nif __name__ == '__main__':\n sys.argv[0] = sys.argv[0].removesuffix('.exe')\n sys.exit(main())\n" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + interpreter = root / "bin/python" + script = root / "pip" + header = ("#!" + str(interpreter) + "\n").encode() + script.write_bytes(header + observed_body) + script.chmod(0o700) + self.assertEqual(header + observed_body, runtime._entrypoint(script, interpreter)[0]) + for contents in (("#!/other/python\n").encode() + observed_body, + header + observed_body + b"print('unexpected')\n", + header + observed_body.replace(b".removesuffix('.exe')", b".removesuffix('.py')")): + with self.subTest(contents=contents): + script.write_bytes(contents) + with self.assertRaisesRegex(ValueError, "bound stock script"): + runtime._entrypoint(script, interpreter) + def test_full_private_bootstrap_and_read_only_reuse_have_no_ambient_or_network_fallback(self): with tempfile.TemporaryDirectory() as temporary: runner = Path(temporary)