Skip to content
Merged
4 changes: 4 additions & 0 deletions .github/actions/bootstrap-patched-pip/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion .github/actions/bootstrap-patched-pip/pip_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions .github/actions/bootstrap-patched-pip/protect-hosted-runtime.py
Original file line number Diff line number Diff line change
@@ -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()
42 changes: 42 additions & 0 deletions .github/workflows/hosted-pip.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions qualification/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
5 changes: 3 additions & 2 deletions tests/test_ci_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down
Loading
Loading