From b5a0c48caa74efdc34f5058676c2248dd1aba153 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:17:05 +0000 Subject: [PATCH 1/2] Turn off APT retries before the unproved APT paths (durable-workflow/.github#295) CVE-2026-109518: a vulnerable APT can follow a redirect on a retried package download and install a swapped package as root. The perf FPM experiment image (php:8.3.35-fpm-trixie, likely apt 3.0.3) and the worker-versioning conformance image (unpinned python:3.13-slim) now write Acquire::Retries "0" before their first APT network call. The production Dockerfile already proves the fixed floor with require-fixed-apt.sh. A new test, run by Public Boundary on every change, refuses any tracked Dockerfile stage whose first networked APT command has neither guard before it. It failed on exactly these two paths before the change. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/public-boundary.yml | 1 + .../worker-versioning-mixed.Dockerfile | 5 +- scripts/perf/Dockerfile.fpm-experiment | 5 +- tests/Unit/Support/apt_no_retries_test.py | 60 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Support/apt_no_retries_test.py diff --git a/.github/workflows/public-boundary.yml b/.github/workflows/public-boundary.yml index 5dc955ae8..e0b34a12c 100644 --- a/.github/workflows/public-boundary.yml +++ b/.github/workflows/public-boundary.yml @@ -50,3 +50,4 @@ jobs: run: | python3 scripts/ci/check-docker-workflow-isolation.py python3 -m unittest tests/Unit/Support/docker_workflow_isolation_policy_test.py + python3 -m unittest tests/Unit/Support/apt_no_retries_test.py diff --git a/scripts/conformance/worker-versioning-mixed.Dockerfile b/scripts/conformance/worker-versioning-mixed.Dockerfile index 2d5550981..9b75ecc0c 100644 --- a/scripts/conformance/worker-versioning-mixed.Dockerfile +++ b/scripts/conformance/worker-versioning-mixed.Dockerfile @@ -8,7 +8,10 @@ ARG PYTHON_SDK_VERSION COPY bootstrap-patched-pip.sh blocked_roots.py /opt/dw-ci/ # apt checks package signatures rather than TLS trust. It refreshes the OS CA # store, which must then exclude the withdrawn roots before any TLS download. -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates php-cli php-curl unzip \ +# CVE-2026-109518 (durable-workflow/.github#295): the unpinned parent's APT may +# follow a redirect on a retried download, so retries are off first. +RUN printf 'Acquire::Retries "0";\n' > /etc/apt/apt.conf.d/99-no-retries \ + && apt-get update && apt-get install -y --no-install-recommends ca-certificates php-cli php-curl unzip \ && rm -r /var/lib/apt/lists/* \ && python3 /opt/dw-ci/blocked_roots.py /etc/ssl/certs/ca-certificates.crt COPY --from=composer /usr/bin/composer /usr/bin/composer diff --git a/scripts/perf/Dockerfile.fpm-experiment b/scripts/perf/Dockerfile.fpm-experiment index 7b1bc2991..89382424f 100644 --- a/scripts/perf/Dockerfile.fpm-experiment +++ b/scripts/perf/Dockerfile.fpm-experiment @@ -3,7 +3,10 @@ FROM ${SERVER_IMAGE} AS published FROM php:8.3.35-fpm-trixie@sha256:ea1c5007c904d2cdf12e4ac87528c9f12407fb8d7636a31a93b4a862b22e2f8f -RUN apt-get update \ +# CVE-2026-109518 (durable-workflow/.github#295): the parent's APT may follow a +# redirect on a retried download, so retries are off before any APT network call. +RUN printf 'Acquire::Retries "0";\n' > /etc/apt/apt.conf.d/99-no-retries \ + && apt-get update \ && apt-get install -y --no-install-recommends libpq5 libzip5 \ && groupmod --gid 1000 www-data \ && usermod --uid 1000 --gid 1000 www-data \ diff --git a/tests/Unit/Support/apt_no_retries_test.py b/tests/Unit/Support/apt_no_retries_test.py new file mode 100644 index 000000000..5becae5e5 --- /dev/null +++ b/tests/Unit/Support/apt_no_retries_test.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Every networked APT path in a tracked Dockerfile runs on a proved fixed APT +or with APT retries off. + +CVE-2026-109518 (durable-workflow/.github#295): a vulnerable APT can follow a +redirect on a retried package download and install a swapped package as root. +In each build stage, the first networked APT command must come after either +the reviewed fixed-floor proof (docker/apt/require-fixed-apt.sh) or a line +that writes Acquire::Retries "0", which removes the retry precondition. +""" + +from pathlib import Path +import re +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[3] +APT = re.compile(r'\bapt(?:-get)?\s+(?:-\S+\s+)*(?:update|install|upgrade|dist-upgrade|full-upgrade)\b') +GUARDS = ('require-fixed-apt.sh', 'Acquire::Retries "0"') +STAGE = re.compile(r'^\s*FROM\s', re.IGNORECASE | re.MULTILINE) + + +def dockerfiles(): + tracked = subprocess.run(['git', 'ls-files'], cwd=ROOT, capture_output=True, text=True, check=True).stdout.split() + for path in tracked: + name = path.rsplit('/', 1)[-1].lower() + if name.startswith('dockerfile') or name.endswith('.dockerfile'): + yield path + + +def unprotected(text): + """Stages whose first networked APT command has no guard before it.""" + for index, stage in enumerate(STAGE.split(text)): + found = APT.search(stage) + if found is None: + continue + before = stage[:found.start()] + if not any(guard in before for guard in GUARDS): + yield index, found.group(0) + + +class AptNoRetriesTest(unittest.TestCase): + def test_every_networked_apt_stage_is_guarded_first(self): + found = [f'{path}: stage {stage}: {command}' + for path in dockerfiles() + for stage, command in unprotected((ROOT / path).read_text(encoding='utf-8'))] + self.assertEqual([], found) + + def test_checker_refuses_a_late_or_missing_guard(self): + retries = "printf 'Acquire::Retries \"0\";\\n' > /etc/apt/apt.conf.d/99-no-retries" + self.assertEqual([], list(unprotected(f'FROM a\nRUN {retries} \\\n && apt-get update\n'))) + self.assertEqual([], list(unprotected('FROM a\nRUN sh require-fixed-apt.sh && apt-get install -y x\n'))) + self.assertEqual(1, len(list(unprotected(f'FROM a\nRUN apt-get update && {retries}\n')))) + self.assertEqual(1, len(list(unprotected('FROM a\nRUN apt-get install -y --no-install-recommends x\n')))) + # A guard in one stage does not cover the next stage. + self.assertEqual(1, len(list(unprotected(f'FROM a\nRUN {retries} && apt-get update\nFROM b\nRUN apt upgrade\n')))) + + +if __name__ == '__main__': + unittest.main() From fb82c13c4bdc0020d87b8e0190f3c8aca96fb7cb Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:45:59 +0000 Subject: [PATCH 2/2] Prove the fixed APT floor in the workflow's inline Dockerfile too (durable-workflow/.github#295) phpunit-feature.yml builds an inline Dockerfile from the base stage that runs apt-get. Its APT is the base's, already proved at the fixed floor, but nothing showed that at the call. The inline stage now re-runs require-fixed-apt.sh before its apt-get, as the production stage does. The APT test also reads Dockerfiles inlined in workflows; it flags the old workflow and passes the new one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/phpunit-feature.yml | 3 ++- tests/Unit/Support/apt_no_retries_test.py | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 106bdfaa7..c071739d1 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -286,7 +286,8 @@ jobs: docker build --target base -t durable-workflow-server-phpunit-base . docker build -t durable-workflow-server-corpus-validator - <<'DOCKERFILE' FROM durable-workflow-server-phpunit-base - RUN apt-get update \ + RUN /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh /usr/local/lib/server-apt/require-fixed-apt.sh \ + && apt-get update \ && apt-get install -y --no-install-recommends git jq \ && rm -rf /var/lib/apt/lists/* \ && server-verify-trust final diff --git a/tests/Unit/Support/apt_no_retries_test.py b/tests/Unit/Support/apt_no_retries_test.py index 5becae5e5..0e56cd4d3 100644 --- a/tests/Unit/Support/apt_no_retries_test.py +++ b/tests/Unit/Support/apt_no_retries_test.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Every networked APT path in a tracked Dockerfile runs on a proved fixed APT -or with APT retries off. +"""Every networked APT path in a tracked Dockerfile, or in a Dockerfile inlined +in a workflow, runs on a proved fixed APT or with APT retries off. CVE-2026-109518 (durable-workflow/.github#295): a vulnerable APT can follow a redirect on a retried package download and install a swapped package as root. @@ -24,7 +24,8 @@ def dockerfiles(): tracked = subprocess.run(['git', 'ls-files'], cwd=ROOT, capture_output=True, text=True, check=True).stdout.split() for path in tracked: name = path.rsplit('/', 1)[-1].lower() - if name.startswith('dockerfile') or name.endswith('.dockerfile'): + inline = path.startswith('.github/workflows/') and name.endswith(('.yml', '.yaml')) + if name.startswith('dockerfile') or name.endswith('.dockerfile') or inline: yield path @@ -52,6 +53,9 @@ def test_checker_refuses_a_late_or_missing_guard(self): self.assertEqual([], list(unprotected('FROM a\nRUN sh require-fixed-apt.sh && apt-get install -y x\n'))) self.assertEqual(1, len(list(unprotected(f'FROM a\nRUN apt-get update && {retries}\n')))) self.assertEqual(1, len(list(unprotected('FROM a\nRUN apt-get install -y --no-install-recommends x\n')))) + # A Dockerfile inlined in a workflow step is checked from its own FROM. + inline = 'run: |\n docker build - <