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/.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..0e56cd4d3 --- /dev/null +++ b/tests/Unit/Support/apt_no_retries_test.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""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. +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() + inline = path.startswith('.github/workflows/') and name.endswith(('.yml', '.yaml')) + if name.startswith('dockerfile') or name.endswith('.dockerfile') or inline: + 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 Dockerfile inlined in a workflow step is checked from its own FROM. + inline = 'run: |\n docker build - <