From 617778f2446dbae02d742f4dfbd191b0bca780da Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:59:03 +0000 Subject: [PATCH] Disable APT retries before setup-php in CI and Sonar Shared contract tests (ci.yml) and Sonar analysis (sonar.yml) run shivammathur/setup-php on unpinned hosted runners, and setup-php can run apt-get itself. Write Acquire::Retries "0" before setup-php in both, so the retry-after-redirect precondition of CVE-2026-109518 is absent (as in cli#121 and waterline#300). Both workflows are complex reviewed workflows, so register their exact new byte identities in protected_policy_context.py. Add test_apt_no_retries (collected by test_ci_trust) so every APT-capable job keeps the line first. The bound setup-php-linux workflows refuse setup-php's APT paths and are unchanged. Refs #295 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++ .github/workflows/sonar.yml | 2 ++ scripts/protected_policy_context.py | 7 +++++ tests/test_apt_no_retries.py | 49 +++++++++++++++++++++++++++++ tests/test_ci_trust.py | 5 +-- 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/test_apt_no_retries.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b984645..2e03ffb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: with: clients: pip,python,composer,curl,git,npm,cargo + - name: Disable APT download retries (CVE-2026-109518) + run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.3' diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index fe24358..d6a06f2 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -30,6 +30,8 @@ jobs: - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python,composer,curl,git,npm,cargo + - name: Disable APT download retries (CVE-2026-109518) + run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.3' diff --git a/scripts/protected_policy_context.py b/scripts/protected_policy_context.py index c401cce..c4367ec 100644 --- a/scripts/protected_policy_context.py +++ b/scripts/protected_policy_context.py @@ -57,6 +57,12 @@ CI_WORKFLOW: "44953364dc6aceff2c2f16e04980805418f7601cecfecbf519244132e9980e3e", SONAR_WORKFLOW: "829ac16a197305d64701a8f423f14544d17658b062fc0c6dca5c1e838d51be0d", } +# The live CI/Sonar workflows above, plus one step before setup-php that writes +# APT's Acquire::Retries "0" (CVE-2026-109518, issue 295). Exact reviewed bytes. +REVIEWED_APT_NO_RETRIES_SOURCE_CI_SUCCESSORS = { + CI_WORKFLOW: "760de748e04a5c652d0174b8bb68b689aed98c17aed10a06b628af6b931719fc", + SONAR_WORKFLOW: "f7fad8367df4a4e9565d97e13aa120d55038ed9c749fdfcd86e1312bfca3cced", +} # The successor remains inert until its separate runtime pin and manual-only # workflow integration. Keep the consumed predecessor's identity unchanged. REVIEWED_INITIAL_PROFILE_SUCCESSORS = { @@ -103,6 +109,7 @@ def reviewed_context_hashes(policy_target: str) -> dict[str, set[str]]: if policy_target == "github-control-plane": registries = (REVIEWED_COMPLEX_WORKFLOWS, REVIEWED_SOURCE_CI_SUCCESSORS, REVIEWED_PHP_CONFIGURATION_SOURCE_CI_SUCCESSORS, + REVIEWED_APT_NO_RETRIES_SOURCE_CI_SUCCESSORS, REVIEWED_INITIAL_PROFILE_SUCCESSORS, REVIEWED_NORMALIZED_PROFILE_SUCCESSORS, REVIEWED_ALTERNATIVES_PROFILE_SUCCESSORS, REVIEWED_SELECTED_ALTERNATIVES_PROFILE_SUCCESSORS, diff --git a/tests/test_apt_no_retries.py b/tests/test_apt_no_retries.py new file mode 100644 index 0000000..99c0290 --- /dev/null +++ b/tests/test_apt_no_retries.py @@ -0,0 +1,49 @@ +"""Every networked APT path in a workflow job runs with APT retries off. + +CVE-2026-109518 (durable-workflow/.github#295): vulnerable APT can follow a +redirect on a retried package download and install a swapped package as root. +Writing Acquire::Retries "0" before the first APT-capable step removes that +precondition. shivammathur/setup-php can run apt-get itself, so it counts. +""" + +from pathlib import Path +import re +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / '.github/workflows' +NO_RETRIES = "echo 'Acquire::Retries \"0\";' | sudo tee /etc/apt/apt.conf.d/99-no-retries" +APT = re.compile(r'\bapt(?:-get)?\b[^\n]{0,80}\b(?:update|install|upgrade|dist-upgrade)\b|--with-deps|install-deps') + + +def apt_capable(step): + return str(step.get('uses', '')).startswith('shivammathur/setup-php@') or bool(APT.search(str(step.get('run') or ''))) + + +def unprotected(document): + for name, job in (document.get('jobs') or {}).items(): + for step in job.get('steps') or []: + if NO_RETRIES in str(step.get('run') or ''): + break + if apt_capable(step): + yield name, step.get('name') or step.get('uses') or step.get('run') + break + + +class AptNoRetriesTest(unittest.TestCase): + def test_every_apt_capable_job_disables_retries_first(self): + found = [] + for path in sorted(WORKFLOWS.glob('*.y*ml')): + for job, step in unprotected(yaml.safe_load(path.read_text())): + found.append(f'{path.name}:{job}: {step}') + self.assertEqual([], found) + + def test_checker_refuses_a_late_or_missing_line(self): + setup = {'uses': 'shivammathur/setup-php@' + 'a' * 40} + line = {'run': NO_RETRIES + ' >/dev/null'} + self.assertEqual([], list(unprotected({'jobs': {'ok': {'steps': [line, setup]}}}))) + self.assertEqual(1, len(list(unprotected({'jobs': {'late': {'steps': [setup, line]}}})))) + self.assertEqual(1, len(list(unprotected({'jobs': {'none': {'steps': [{'run': 'sudo apt-get update'}]}}})))) + self.assertEqual(1, len(list(unprotected({'jobs': {'deps': {'steps': [{'run': 'npx playwright install --with-deps chromium'}]}}})))) diff --git a/tests/test_ci_trust.py b/tests/test_ci_trust.py index c7e6eaf..240dac1 100644 --- a/tests/test_ci_trust.py +++ b/tests/test_ci_trust.py @@ -19,7 +19,7 @@ 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_hosted_runtime_parent) + test_hosted_runtime_parent, test_apt_no_retries) ROOT = Path(__file__).resolve().parents[1] @@ -39,7 +39,8 @@ def load_tests(loader, standard_tests, pattern): 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_hosted_runtime_parent, - 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_apt_no_retries): standard_tests.addTests(loader.loadTestsFromModule(module)) return standard_tests