From 17eb6c7d1c007fba8fbf5868903462d994618872 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:18:19 +0000 Subject: [PATCH 1/6] Constrain installer protocols and production privileges --- .devcontainer/docker/Dockerfile | 2 +- .github/workflows/ci.yml | 2 +- Dockerfile | 10 ++- docker/install-phpredis.sh | 2 + polyglot/python_worker/Dockerfile | 2 +- tests/ci/test_source_security_boundaries.py | 97 +++++++++++++++++++++ 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 tests/ci/test_source_security_boundaries.py diff --git a/.devcontainer/docker/Dockerfile b/.devcontainer/docker/Dockerfile index ce8b1764..f27b712d 100644 --- a/.devcontainer/docker/Dockerfile +++ b/.devcontainer/docker/Dockerfile @@ -292,7 +292,7 @@ RUN set -eux; \ sh /usr/local/bin/install-npm; \ cli_version="${DURABLE_WORKFLOW_CLI_VERSION:-$(php -r '$tuple = json_decode(file_get_contents("/usr/local/share/sample-app/qualified-artifact-tuple.json"), true, flags: JSON_THROW_ON_ERROR); echo $tuple["artifacts"]["cli"];')}"; \ test -n "$cli_version"; \ - curl -fsSL https://durable-workflow.com/install.sh -o /tmp/dw-install.sh; \ + curl --proto '=https' --proto-redir '=https' -fsSL https://durable-workflow.com/install.sh -o /tmp/dw-install.sh; \ VERSION="$cli_version" \ DURABLE_WORKFLOW_INSTALL_DIR=/usr/local/bin \ sh /tmp/dw-install.sh; \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7211aa23..99cf3e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: python-version: '3.13' - name: Bootstrap patched pip trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@76f333804e68725059039fd54df241d40d855cd3 # clean-trust - - run: python -m pip install PyYAML==6.0.2 + - run: python -m pip install --only-binary=:all: PyYAML==6.0.2 - name: Require centrally approved immutable action commits run: >- python .central-action-policy/scripts/qualification_policy.py validate diff --git a/Dockerfile b/Dockerfile index 1e707538..48729aec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,7 +73,9 @@ RUN npm run build FROM base AS production ARG SAMPLE_APP_COMMIT= -ENV SAMPLE_APP_COMMIT=${SAMPLE_APP_COMMIT} +ENV SAMPLE_APP_COMMIT=${SAMPLE_APP_COMMIT} \ + HOME=/home/www-data \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright LABEL org.opencontainers.image.revision="${SAMPLE_APP_COMMIT}" COPY --from=vendor /app /app @@ -96,7 +98,11 @@ RUN node /usr/local/share/sample-app/verify-node-trust.mjs \ && (php artisan vendor:publish --tag=waterline-assets --force 2>/dev/null || true) COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint -RUN chmod +x /usr/local/bin/app-entrypoint +RUN chmod +x /usr/local/bin/app-entrypoint \ + && mkdir -p "$HOME" "$PLAYWRIGHT_BROWSERS_PATH" \ + && chown -R www-data:www-data /app "$HOME" "$PLAYWRIGHT_BROWSERS_PATH" + +USER www-data EXPOSE 8000 diff --git a/docker/install-phpredis.sh b/docker/install-phpredis.sh index d60f7234..eec43271 100644 --- a/docker/install-phpredis.sh +++ b/docker/install-phpredis.sh @@ -20,6 +20,8 @@ trap cleanup EXIT HUP INT TERM curl \ --fail \ --location \ + --proto '=https' \ + --proto-redir '=https' \ --silent \ --show-error \ --connect-timeout 10 \ diff --git a/polyglot/python_worker/Dockerfile b/polyglot/python_worker/Dockerfile index bd3aff72..67e7c7d0 100644 --- a/polyglot/python_worker/Dockerfile +++ b/polyglot/python_worker/Dockerfile @@ -18,7 +18,7 @@ RUN set -eux; \ apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://durable-workflow.com/install.sh -o /tmp/dw-install.sh \ + && curl --proto '=https' --proto-redir '=https' -fsSL https://durable-workflow.com/install.sh -o /tmp/dw-install.sh \ && VERSION="${DURABLE_WORKFLOW_CLI_VERSION}" DURABLE_WORKFLOW_INSTALL_DIR=/usr/local/bin sh /tmp/dw-install.sh \ && rm /tmp/dw-install.sh \ && dw --version diff --git a/tests/ci/test_source_security_boundaries.py b/tests/ci/test_source_security_boundaries.py new file mode 100644 index 00000000..bd14afbf --- /dev/null +++ b/tests/ci/test_source_security_boundaries.py @@ -0,0 +1,97 @@ +import pathlib +import re +import shlex +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DOWNLOADS = { + "docker/install-phpredis.sh": "$PHPREDIS_SOURCE_URL", + ".devcontainer/docker/Dockerfile": "https://durable-workflow.com/install.sh", + "polyglot/python_worker/Dockerfile": "https://durable-workflow.com/install.sh", +} + + +def download_commands(source, url): + logical = source.replace("\\\n", " ") + commands = [ + shlex.split(re.split(r"&&|;|\n", logical[match.start():], maxsplit=1)[0]) + for match in re.finditer(r"\bcurl\s+(?=-)", logical) + ] + return [arguments for arguments in commands if url in arguments] + + +def require_https_downloads(source, url): + commands = download_commands(source, url) + assert commands, "Expected an actual curl download command" + for arguments in commands: + for option in ("--proto", "--proto-redir"): + assert option in arguments, "Missing HTTPS protocol restriction" + position = arguments.index(option) + assert arguments[position + 1] == "=https", "Download permits another protocol" + + +def require_binary_yaml(source): + commands = re.findall(r"^\s*- run: (python -m pip install[^\n]*PyYAML==[^\n]+)", source, re.MULTILINE) + assert commands, "Expected the action-policy YAML dependency installation" + for command in commands: + assert "--only-binary=:all:" in shlex.split(command), "YAML installation allows source builds" + + +def require_nonroot_production(source): + final = re.split(r"^FROM ", source, flags=re.MULTILINE)[-1] + users = re.findall(r"^USER (\S+)", final, re.MULTILINE) + assert users and users[-1].split(":")[0] not in ("root", "0"), "Production runtime runs as root" + + +class SourceSecurityBoundaryTests(unittest.TestCase): + def test_every_reported_installer_restricts_initial_and_redirect_protocols(self): + for path, url in DOWNLOADS.items(): + with self.subTest(path=path): + require_https_downloads((ROOT / path).read_text(), url) + + def test_either_download_protocol_restriction_cannot_be_removed(self): + for path, url in DOWNLOADS.items(): + source = (ROOT / path).read_text() + for option in ("--proto", "--proto-redir"): + with self.subTest(path=path, option=option): + arguments = download_commands(source, url)[0] + changed = list(arguments) + position = changed.index(option) + del changed[position:position + 2] + self.assertNotEqual(arguments, changed) + with self.assertRaisesRegex(AssertionError, "HTTPS protocol"): + require_https_downloads(shlex.join(changed), url) + + def test_action_policy_yaml_installation_does_not_execute_source_builds(self): + require_binary_yaml((ROOT / ".github/workflows/ci.yml").read_text()) + + def test_binary_only_yaml_policy_cannot_be_removed(self): + source = (ROOT / ".github/workflows/ci.yml").read_text() + changed = source.replace("--only-binary=:all:", "", 1) + self.assertNotEqual(source, changed) + with self.assertRaisesRegex(AssertionError, "source builds"): + require_binary_yaml(changed) + + def test_production_entrypoint_inherits_a_nonroot_user(self): + require_nonroot_production((ROOT / "Dockerfile").read_text()) + + def test_production_user_cannot_be_removed_or_overridden_with_root(self): + source = (ROOT / "Dockerfile").read_text() + for changed in (re.sub(r"^USER .*\n", "", source, flags=re.MULTILINE), source + "\nUSER root\n"): + with self.subTest(changed=changed[-40:]): + with self.assertRaisesRegex(AssertionError, "runs as root"): + require_nonroot_production(changed) + + def test_nonroot_browser_and_application_state_have_writable_locations(self): + source = (ROOT / "Dockerfile").read_text() + final = re.split(r"^FROM ", source, flags=re.MULTILINE)[-1] + self.assertIn("HOME=/home/www-data", final) + self.assertIn("PLAYWRIGHT_BROWSERS_PATH=/ms-playwright", final) + self.assertRegex(final, r'chown -R www-data:www-data /app "\$HOME" "\$PLAYWRIGHT_BROWSERS_PATH"') + self.assertLess(final.index("PLAYWRIGHT_BROWSERS_PATH="), final.index("playwright install")) + self.assertLess(final.index("chown -R"), final.index("USER www-data")) + + +if __name__ == "__main__": + unittest.main() From f043273f0da168d4b82319304cd52a9722209a68 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:21:29 +0000 Subject: [PATCH 2/6] Quote the wheel-only action policy command --- .github/workflows/ci.yml | 2 +- tests/ci/test_source_security_boundaries.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99cf3e12..1763c299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: python-version: '3.13' - name: Bootstrap patched pip trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@76f333804e68725059039fd54df241d40d855cd3 # clean-trust - - run: python -m pip install --only-binary=:all: PyYAML==6.0.2 + - run: 'python -m pip install --only-binary=:all: PyYAML==6.0.2' - name: Require centrally approved immutable action commits run: >- python .central-action-policy/scripts/qualification_policy.py validate diff --git a/tests/ci/test_source_security_boundaries.py b/tests/ci/test_source_security_boundaries.py index bd14afbf..0a4730d7 100644 --- a/tests/ci/test_source_security_boundaries.py +++ b/tests/ci/test_source_security_boundaries.py @@ -32,9 +32,11 @@ def require_https_downloads(source, url): def require_binary_yaml(source): - commands = re.findall(r"^\s*- run: (python -m pip install[^\n]*PyYAML==[^\n]+)", source, re.MULTILINE) + commands = re.findall(r"^\s*- run: (.+PyYAML==[^\n]+)", source, re.MULTILINE) assert commands, "Expected the action-policy YAML dependency installation" for command in commands: + if command[0] in ("'", '"') and command[-1] == command[0]: + command = command[1:-1] assert "--only-binary=:all:" in shlex.split(command), "YAML installation allows source builds" From 74affb9310dba5958a631f0ebac0126307309900 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:15:52 +0000 Subject: [PATCH 3/6] Bind fixed parents and guarded Python bootstrap before CI package requests --- .github/workflows/polyglot-validation.yml | 8 ++ .github/workflows/sdk-activity-recovery.yml | 8 ++ .github/workflows/sdk-activity-upgrades.yml | 8 ++ .../sdk-application-package-timers.yml | 8 ++ .github/workflows/sdk-children.yml | 8 ++ .github/workflows/sdk-external-effects.yml | 8 ++ .github/workflows/sdk-namespaces.yml | 8 ++ .github/workflows/sdk-package-timers.yml | 8 ++ .github/workflows/sdk-search-attributes.yml | 8 ++ .github/workflows/sdk-timers.yml | 8 ++ .github/workflows/sdk-unversioned-timers.yml | 8 ++ .github/workflows/sdk-updates.yml | 8 ++ .github/workflows/smoke.yml | 12 ++ Dockerfile | 21 ++-- README.md | 8 ++ docker/python/prepare-trust.py | 23 ++++ polyglot/README.md | 4 + polyglot/docker-compose.yml | 9 +- polyglot/python_worker/Dockerfile | 34 ++++-- scripts/polyglot.sh | 2 + scripts/prepare-apt-build-source.sh | 33 ++++++ scripts/sdk-activity-recovery.sh | 1 + scripts/sdk-children.sh | 1 + scripts/sdk-namespaces.sh | 1 + scripts/sdk-search-attributes.sh | 1 + scripts/sdk-timers.sh | 1 + scripts/sdk-updates.sh | 1 + tests/ci/test_producer_prerequisites.py | 103 ++++++++++++++++++ 28 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 docker/python/prepare-trust.py create mode 100755 scripts/prepare-apt-build-source.sh create mode 100644 tests/ci/test_producer_prerequisites.py diff --git a/.github/workflows/polyglot-validation.yml b/.github/workflows/polyglot-validation.yml index 66667a43..e3187ae8 100644 --- a/.github/workflows/polyglot-validation.yml +++ b/.github/workflows/polyglot-validation.yml @@ -24,6 +24,14 @@ jobs: POLYGLOT_COMPOSE_PROJECT_NAME: sample-app-polyglot-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Test smoke metadata run: python3 -m unittest discover -s polyglot/python_worker/tests -v - name: Run the documented service-mode example diff --git a/.github/workflows/sdk-activity-recovery.yml b/.github/workflows/sdk-activity-recovery.yml index 01a2b35b..238a1547 100644 --- a/.github/workflows/sdk-activity-recovery.yml +++ b/.github/workflows/sdk-activity-recovery.yml @@ -28,6 +28,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Execute documented activity retry and recovery diff --git a/.github/workflows/sdk-activity-upgrades.yml b/.github/workflows/sdk-activity-upgrades.yml index 94a994bc..c663b8e7 100644 --- a/.github/workflows/sdk-activity-upgrades.yml +++ b/.github/workflows/sdk-activity-upgrades.yml @@ -23,6 +23,14 @@ jobs: SDK_ACTIVITY_UPGRADES_COMPOSE_PROJECT_NAME: sample-app-sdk-activity-upgrades-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Test original-activity and package evidence diff --git a/.github/workflows/sdk-application-package-timers.yml b/.github/workflows/sdk-application-package-timers.yml index 2481d4bd..d9d5f692 100644 --- a/.github/workflows/sdk-application-package-timers.yml +++ b/.github/workflows/sdk-application-package-timers.yml @@ -23,6 +23,14 @@ jobs: SDK_TIMERS_COMPOSE_PROJECT_NAME: sample-app-sdk-application-timers-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Replay original timers across SDK and application replacements diff --git a/.github/workflows/sdk-children.yml b/.github/workflows/sdk-children.yml index 004a169d..f2e3597e 100644 --- a/.github/workflows/sdk-children.yml +++ b/.github/workflows/sdk-children.yml @@ -23,6 +23,14 @@ jobs: SDK_CHILDREN_COMPOSE_PROJECT_NAME: sample-app-sdk-children-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 diff --git a/.github/workflows/sdk-external-effects.yml b/.github/workflows/sdk-external-effects.yml index 3a55690a..89512e17 100644 --- a/.github/workflows/sdk-external-effects.yml +++ b/.github/workflows/sdk-external-effects.yml @@ -30,6 +30,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Prove the downstream idempotency contract diff --git a/.github/workflows/sdk-namespaces.yml b/.github/workflows/sdk-namespaces.yml index d46df316..2ef6ae4a 100644 --- a/.github/workflows/sdk-namespaces.yml +++ b/.github/workflows/sdk-namespaces.yml @@ -23,6 +23,14 @@ jobs: SDK_NAMESPACES_COMPOSE_PROJECT_NAME: sample-app-sdk-namespaces-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Run the documented namespace experiment diff --git a/.github/workflows/sdk-package-timers.yml b/.github/workflows/sdk-package-timers.yml index 5fbe54c8..5193ea30 100644 --- a/.github/workflows/sdk-package-timers.yml +++ b/.github/workflows/sdk-package-timers.yml @@ -23,6 +23,14 @@ jobs: SDK_TIMERS_COMPOSE_PROJECT_NAME: sample-app-sdk-package-timers-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Replay original timers across installed SDK packages diff --git a/.github/workflows/sdk-search-attributes.yml b/.github/workflows/sdk-search-attributes.yml index 2b888884..b2f3c41d 100644 --- a/.github/workflows/sdk-search-attributes.yml +++ b/.github/workflows/sdk-search-attributes.yml @@ -23,6 +23,14 @@ jobs: SDK_SEARCH_ATTRIBUTES_COMPOSE_PROJECT_NAME: sample-app-search-attributes-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Run the documented search attribute experiment diff --git a/.github/workflows/sdk-timers.yml b/.github/workflows/sdk-timers.yml index 94f4806b..d09ca3e1 100644 --- a/.github/workflows/sdk-timers.yml +++ b/.github/workflows/sdk-timers.yml @@ -23,6 +23,14 @@ jobs: SDK_TIMERS_COMPOSE_PROJECT_NAME: sample-app-sdk-timers-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Run the documented timer experiment diff --git a/.github/workflows/sdk-unversioned-timers.yml b/.github/workflows/sdk-unversioned-timers.yml index 65064caa..addfffdc 100644 --- a/.github/workflows/sdk-unversioned-timers.yml +++ b/.github/workflows/sdk-unversioned-timers.yml @@ -45,6 +45,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Introduce versioning into pending timer workflows diff --git a/.github/workflows/sdk-updates.yml b/.github/workflows/sdk-updates.yml index f76ef404..62a46f14 100644 --- a/.github/workflows/sdk-updates.yml +++ b/.github/workflows/sdk-updates.yml @@ -24,6 +24,14 @@ jobs: SDK_UPDATES_FINISH_RACE_REPETITIONS: '10' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Resolve the exact published tuple run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - name: Run the documented update experiment diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index e0cfeb4e..022025db 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -29,9 +29,18 @@ jobs: SAMPLE_APP_COMMIT: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the accepted central build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false - name: Build the production input without a prior cache run: | test "$(git rev-parse HEAD)" = "$SAMPLE_APP_COMMIT" + scripts/prepare-apt-build-source.sh docker compose build --pull --no-cache app - name: Start the qualified build run: docker compose up -d --wait app worker @@ -43,6 +52,9 @@ jobs: test "$(docker image inspect "$image_id" --format '{{.Os}}/{{.Architecture}}')" = "$EXPECTED_PLATFORM" test "$(docker image inspect "$image_id" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$SAMPLE_APP_COMMIT" docker image inspect "$image_id" --format 'image={{.Id}} platform={{.Os}}/{{.Architecture}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}' + test "$(docker compose exec -T app id -u)" != 0 + docker compose exec -T app php -r '$path = tempnam("/app/storage", "permission-"); if ($path === false || file_put_contents($path, "non-root storage probe") === false || !unlink($path)) { exit(1); }' + docker compose exec -T app /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh docker compose exec -T app node docker/verify-node-trust.mjs docker compose exec -T app node --test tests/Node/ProductionNodeTrustTest.mjs docker compose exec -T app php /usr/local/share/sample-app/verify-runtime-trust.php /usr/bin/composer diff --git a/Dockerfile b/Dockerfile index 48729aec..65a1fc57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,25 @@ FROM composer:2.11.0@sha256:278dfc7bd35169965d03f87194816f43bf4e6b6ea61707c2114e52ad12fe24ce AS composer -FROM php:8.4.27-cli-bookworm@sha256:54b467ede1db96c66675e7b78457dd43729d0e7be31978d4a1e5c069ceabcf76 AS brotli-build +FROM php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe AS brotli-build +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh COPY --from=composer /usr/bin/composer /usr/local/bin/composer COPY docker/verify-runtime-trust.php /usr/local/share/sample-app/verify-runtime-trust.php COPY docker/brotli/version docker/brotli/install-vendor.sh docker/brotli/verify-runtime.php docker/brotli/verify-system-brotli.c /usr/src/brotli-inputs/ -RUN php /usr/local/share/sample-app/verify-runtime-trust.php /usr/local/bin/composer \ +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && php /usr/local/share/sample-app/verify-runtime-trust.php /usr/local/bin/composer \ && sh /usr/src/brotli-inputs/install-vendor.sh /usr/src/brotli-inputs gcc libc6-dev \ && mkdir -p /out \ && cc -O2 /usr/src/brotli-inputs/verify-system-brotli.c -lbrotlidec -lbrotlienc -ldl -o /out/verify-system-brotli \ && /out/verify-system-brotli \ && cp /usr/src/brotli-inputs/version /usr/src/brotli-inputs/install-vendor.sh /usr/src/brotli-inputs/verify-runtime.php /out/ -FROM php:8.4.27-cli-bookworm@sha256:54b467ede1db96c66675e7b78457dd43729d0e7be31978d4a1e5c069ceabcf76 AS base +FROM php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe AS base +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh COPY --from=brotli-build /out /usr/local/share/sample-app/brotli -RUN sh /usr/local/share/sample-app/brotli/install-vendor.sh /usr/local/share/sample-app/brotli \ +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && sh /usr/local/share/sample-app/brotli/install-vendor.sh /usr/local/share/sample-app/brotli \ && php /usr/local/share/sample-app/brotli/verify-runtime.php \ && rm -rf /var/lib/apt/lists/* @@ -23,7 +27,8 @@ COPY --from=composer /usr/bin/composer /usr/bin/composer COPY docker/install-phpredis.sh /usr/local/bin/install-phpredis COPY docker/verify-runtime-trust.php docker/verify-node-trust.mjs /usr/local/share/sample-app/ -RUN php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ && apt-get update && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ curl ffmpeg git libnspr4 libnss3 libpq-dev libzip-dev unzip \ @@ -52,7 +57,8 @@ RUN php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v compo && bash /usr/local/bin/install-composer-artifacts COPY . . -RUN composer dump-autoload --optimize +RUN rm -rf /app/.central-apt-source \ + && composer dump-autoload --optimize # ── Frontend assets ────────────────────────────────────── FROM node:22.23.4-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS assets @@ -85,7 +91,8 @@ COPY --from=assets /app/node_modules /app/node_modules COPY --from=assets /app/public/build /app/public/build COPY .env.example /app/.env.example -RUN node /usr/local/share/sample-app/verify-node-trust.mjs \ +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && node /usr/local/share/sample-app/verify-node-trust.mjs \ && ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ && PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT=120000 ./node_modules/.bin/playwright install --with-deps chromium \ diff --git a/README.md b/README.md index c9bf5158..3ac90cef 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,13 @@ PHP workflow sends an order calculation to a Python activity, then sends that result to a Rust activity that produces a receipt. The final result identifies all three runtimes and the artifact versions used for the run. +The build scripts prepare the pinned public central source and verify it before +building. They use no credentials. For a manual Compose build, prepare it first: + +```bash +scripts/prepare-apt-build-source.sh +``` + The qualified tuple in [`polyglot/qualified-artifact-tuple.json`](polyglot/qualified-artifact-tuple.json) pins every published artifact the journeys use: native Durable Workflow Server @@ -282,6 +289,7 @@ cp .env.example .env composer install npm install php artisan key:generate +scripts/prepare-apt-build-source.sh docker compose up -d php artisan migrate composer run dev diff --git a/docker/python/prepare-trust.py b/docker/python/prepare-trust.py new file mode 100644 index 00000000..b02811e3 --- /dev/null +++ b/docker/python/prepare-trust.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Verify the official parent's system bundle before bootstrap requests.""" + +from pathlib import Path +import ssl + +from export_trust import export_bundle + + +def prepare(): + state = Path("/tmp/sample-app-python-bootstrap") + state.mkdir(exist_ok=True) + # This stage qualifies curl's system trust, not the parent's old pip. + # pip remains offline until the existing wheel producer and pip guard pass. + return export_bundle( + Path(ssl.get_default_verify_paths().cafile), state, + state / "trust.env", state / "outputs", + clients=("curl", "git", "python"), + ) + + +if __name__ == "__main__": + prepare() diff --git a/polyglot/README.md b/polyglot/README.md index 278247f1..475df878 100644 --- a/polyglot/README.md +++ b/polyglot/README.md @@ -12,6 +12,9 @@ From the repository root: scripts/polyglot.sh ``` +The script prepares and verifies the pinned public central build source. Before +invoking a Compose build directly, run `scripts/prepare-apt-build-source.sh` once. + The command: 1. reads the stable artifact versions in `qualified-artifact-tuple.json`; @@ -71,6 +74,7 @@ an isolated project: ```bash while IFS= read -r assignment; do export "$assignment"; done \ < <(scripts/resolve-current-artifacts.sh) +scripts/prepare-apt-build-source.sh export COMPOSE_PROJECT_NAME="sample-app-polyglot-matrix-${USER:-user}" docker compose \ diff --git a/polyglot/docker-compose.yml b/polyglot/docker-compose.yml index 1c148b42..e4d5ebcc 100644 --- a/polyglot/docker-compose.yml +++ b/polyglot/docker-compose.yml @@ -162,7 +162,8 @@ services: python-activity-worker: build: - context: ./python_worker + context: .. + dockerfile: polyglot/python_worker/Dockerfile args: DURABLE_WORKFLOW_CLI_VERSION: *durable-cli-version DURABLE_WORKFLOW_PYTHON_SDK_VERSION: *durable-python-sdk-version @@ -373,7 +374,8 @@ services: smoke: init: true build: - context: ./python_worker + context: .. + dockerfile: polyglot/python_worker/Dockerfile args: DURABLE_WORKFLOW_CLI_VERSION: *durable-cli-version DURABLE_WORKFLOW_PYTHON_SDK_VERSION: *durable-python-sdk-version @@ -440,7 +442,8 @@ services: demo: build: - context: ./python_worker + context: .. + dockerfile: polyglot/python_worker/Dockerfile args: DURABLE_WORKFLOW_CLI_VERSION: *durable-cli-version DURABLE_WORKFLOW_PYTHON_SDK_VERSION: *durable-python-sdk-version diff --git a/polyglot/python_worker/Dockerfile b/polyglot/python_worker/Dockerfile index 67e7c7d0..6dc157d1 100644 --- a/polyglot/python_worker/Dockerfile +++ b/polyglot/python_worker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.12-slim-bookworm@sha256:935c3c5b12357a7e10caa9c1e6ca5692344d97cf229832ef584be2cec9fc03df ARG DURABLE_WORKFLOW_CLI_VERSION ARG DURABLE_WORKFLOW_PYTHON_SDK_VERSION @@ -12,25 +12,45 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh +COPY .central-apt-source/.github/actions/bootstrap-patched-pip/export-trust.py /usr/local/share/sample-app/python/export_trust.py +COPY docker/python /usr/local/share/sample-app/python +COPY .devcontainer/docker/verify-python-trust.py /usr/local/share/sample-app/verify-python-trust.py + RUN set -eux; \ + /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh; \ + python3 /usr/local/share/sample-app/python/prepare-trust.py; \ + set -a; . /tmp/sample-app-python-bootstrap/trust.env; set +a; \ test -n "$DURABLE_WORKFLOW_CLI_VERSION"; \ test -n "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION"; \ apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ + && apt-get install -y --no-install-recommends ca-certificates curl git \ && rm -rf /var/lib/apt/lists/* \ && curl --proto '=https' --proto-redir '=https' -fsSL https://durable-workflow.com/install.sh -o /tmp/dw-install.sh \ && VERSION="${DURABLE_WORKFLOW_CLI_VERSION}" DURABLE_WORKFLOW_INSTALL_DIR=/usr/local/bin sh /tmp/dw-install.sh \ && rm /tmp/dw-install.sh \ && dw --version -RUN pip install --no-cache-dir \ - "fastavro==${FASTAVRO_PYTHON_VERSION}" \ - "durable-workflow==${DURABLE_WORKFLOW_PYTHON_SDK_VERSION}" +RUN set -eux; \ + set -a; . /tmp/sample-app-python-bootstrap/trust.env; set +a; \ + python3 /usr/local/share/sample-app/python/build-pip.py /tmp/sample-app-python-wheels; \ + python3 -I -m pip --isolated --disable-pip-version-check install \ + --no-index --no-deps --upgrade /tmp/sample-app-python-wheels/*.whl; \ + rm -rf /tmp/sample-app-python-wheels; \ + python3 /usr/local/share/sample-app/verify-python-trust.py --pip-only; \ + python3 -I -m pip --isolated --disable-pip-version-check install \ + --cert "$SSL_CERT_FILE" --no-cache-dir \ + "fastavro==${FASTAVRO_PYTHON_VERSION}" \ + "durable-workflow==${DURABLE_WORKFLOW_PYTHON_SDK_VERSION}"; \ + python3 /usr/local/share/sample-app/verify-python-trust.py; \ + /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh; \ + apt-get purge -y --auto-remove git; \ + rm -rf /var/lib/apt/lists/* -COPY activities.py ./activities.py +COPY polyglot/python_worker/activities.py ./activities.py # Bake the polyglot smoke scripts into the image so the smoke service does # not depend on a bind mount that some CI runners do not honor in all # configurations. The python_worker activity service ignores them. -COPY scripts ./scripts +COPY polyglot/python_worker/scripts ./scripts CMD ["python", "activities.py"] diff --git a/scripts/polyglot.sh b/scripts/polyglot.sh index 3ac2a275..296c7ebb 100755 --- a/scripts/polyglot.sh +++ b/scripts/polyglot.sh @@ -43,6 +43,8 @@ case "${1:-}" in ;; esac +"$repo_root/scripts/prepare-apt-build-source.sh" + printf '==> PolyglotWorkflow: building PHP %s, Python %s, and Rust %s workers\n' \ "$DURABLE_WORKFLOW_PHP_SDK_VERSION" \ "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION" \ diff --git a/scripts/prepare-apt-build-source.sh b/scripts/prepare-apt-build-source.sh new file mode 100755 index 00000000..cea28928 --- /dev/null +++ b/scripts/prepare-apt-build-source.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="$repo_root/.central-apt-source" +expected_commit=18783be6bef737cc778e4c7282a8797e39daa61a + +if [[ ! -e "$source_dir" ]]; then + source_temp="$(mktemp -d "$repo_root/.central-apt-source.XXXXXX")" + trap 'rm -rf -- "$source_temp"' EXIT + source_env=(PATH=/usr/bin:/bin:/usr/local/bin GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0) + for variable in SSL_CERT_FILE SSL_CERT_DIR GIT_SSL_CAINFO GIT_SSL_CAPATH CURL_CA_BUNDLE; do + if [[ -n "${!variable:-}" ]]; then source_env+=("$variable=${!variable}"); fi + done + source_git() { + env -i "${source_env[@]}" git \ + -c credential.helper= -c core.askPass= -c core.hooksPath=/dev/null \ + -c http.extraHeader= -c http.followRedirects=false \ + -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=45 "$@" + } + source_git init --quiet "$source_temp" + source_git -C "$source_temp" fetch --quiet --depth=1 \ + https://github.com/durable-workflow/.github.git "$expected_commit" + source_git -C "$source_temp" checkout --quiet --detach FETCH_HEAD + mv -- "$source_temp" "$source_dir" + trap - EXIT +fi +test "$(git -C "$source_dir" rev-parse HEAD)" = "$expected_commit" +cd "$source_dir" +printf '%s\n' \ + '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 docker/apt/require-fixed-bookworm-apt.sh' \ + '73f8aa7d627dd63224fa1bde3db840424befa4a01f71f0b1d8b743615a4952dd .github/actions/bootstrap-patched-pip/export-trust.py' \ + | sha256sum --check --strict - diff --git a/scripts/sdk-activity-recovery.sh b/scripts/sdk-activity-recovery.sh index 4a7ec776..3d4397b0 100755 --- a/scripts/sdk-activity-recovery.sh +++ b/scripts/sdk-activity-recovery.sh @@ -17,6 +17,7 @@ if [[ -z "$result_dir" ]]; then result_dir="$(mktemp -d "${TMPDIR:-/tmp}/dw-sdk- mkdir -p "$result_dir/proof" "$result_dir/logs" result_dir="$(cd "$result_dir" && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/prepare-apt-build-source.sh" export COMPOSE_PROJECT_NAME="${SDK_ACTIVITY_RECOVERY_COMPOSE_PROJECT_NAME:-sample-app-activity-recovery-$(date -u +%Y%m%d%H%M%S)}" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || exit 2 export ACTIVITY_RECOVERY_PROOF_DIR="$result_dir/proof" diff --git a/scripts/sdk-children.sh b/scripts/sdk-children.sh index 3e77a242..c327ddc6 100755 --- a/scripts/sdk-children.sh +++ b/scripts/sdk-children.sh @@ -14,6 +14,7 @@ if [[ "${1:-}" == --help ]]; then fi [[ $# == 0 ]] || exit 2 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/prepare-apt-build-source.sh" export COMPOSE_PROJECT_NAME="${SDK_CHILDREN_COMPOSE_PROJECT_NAME:-sample-app-sdk-children-$(date -u +%Y%m%d%H%M%S)}" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || exit 2 export DURABLE_WORKFLOW_CHILD_ID="$COMPOSE_PROJECT_NAME" diff --git a/scripts/sdk-namespaces.sh b/scripts/sdk-namespaces.sh index ffb9b0fc..c457a520 100755 --- a/scripts/sdk-namespaces.sh +++ b/scripts/sdk-namespaces.sh @@ -16,6 +16,7 @@ if [[ "${1:-}" == --help ]]; then fi [[ $# == 0 ]] || exit 2 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/prepare-apt-build-source.sh" export COMPOSE_PROJECT_NAME="${SDK_NAMESPACES_COMPOSE_PROJECT_NAME:-sample-app-sdk-namespaces-$(date -u +%Y%m%d%H%M%S)}" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || exit 2 export NAMESPACE_UID="$(id -u)" NAMESPACE_GID="$(id -g)" diff --git a/scripts/sdk-search-attributes.sh b/scripts/sdk-search-attributes.sh index 1de80e93..312ee311 100755 --- a/scripts/sdk-search-attributes.sh +++ b/scripts/sdk-search-attributes.sh @@ -11,6 +11,7 @@ if [[ "${1:-}" == --help ]]; then fi [[ $# == 0 ]] || exit 2 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/prepare-apt-build-source.sh" export COMPOSE_PROJECT_NAME="${SDK_SEARCH_ATTRIBUTES_COMPOSE_PROJECT_NAME:-sample-app-search-attributes-$(date -u +%Y%m%d%H%M%S)}" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || exit 2 export SEARCH_ATTRIBUTES_UID="$(id -u)" SEARCH_ATTRIBUTES_GID="$(id -g)" diff --git a/scripts/sdk-timers.sh b/scripts/sdk-timers.sh index 23d22082..3dd5e82f 100755 --- a/scripts/sdk-timers.sh +++ b/scripts/sdk-timers.sh @@ -36,6 +36,7 @@ fi [[ $# == 0 ]] || { printf '%s\n' 'Use --help for usage.' >&2; exit 2; } repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/prepare-apt-build-source.sh" export COMPOSE_PROJECT_NAME="${SDK_TIMERS_COMPOSE_PROJECT_NAME:-sample-app-sdk-timers-$(date -u +%Y%m%d%H%M%S)}" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || exit 2 export COMPOSE_PROFILES=timers diff --git a/scripts/sdk-updates.sh b/scripts/sdk-updates.sh index b5bc8510..3737db97 100755 --- a/scripts/sdk-updates.sh +++ b/scripts/sdk-updates.sh @@ -14,6 +14,7 @@ export SDK_UPDATES_FINISH_RACE_REPETITIONS="${SDK_UPDATES_FINISH_RACE_REPETITION export POLYGLOT_UPDATE_DIAGNOSTICS=1 [[ "$SDK_UPDATES_FINISH_RACE_REPETITIONS" =~ ^([0-9]|1[0-9]|20)$ ]] || exit 2 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/prepare-apt-build-source.sh" export COMPOSE_PROJECT_NAME="${SDK_UPDATES_COMPOSE_PROJECT_NAME:-sample-app-sdk-updates-$(date -u +%Y%m%d%H%M%S)}" [[ "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || exit 2 export DURABLE_WORKFLOW_UPDATE_ID="$COMPOSE_PROJECT_NAME" diff --git a/tests/ci/test_producer_prerequisites.py b/tests/ci/test_producer_prerequisites.py new file mode 100644 index 00000000..9bff2081 --- /dev/null +++ b/tests/ci/test_producer_prerequisites.py @@ -0,0 +1,103 @@ +import importlib.util +import pathlib +import re +import sys +import types +import unittest +from unittest import mock + +from test_bookworm_consumer import GUARD, instructions, protected_php_stages + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +PYTHON_PARENT = "python:3.12-slim-bookworm@sha256:935c3c5b12357a7e10caa9c1e6ca5692344d97cf229832ef584be2cec9fc03df" +CENTRAL_COMMIT = "18783be6bef737cc778e4c7282a8797e39daa61a" +WORKFLOWS = ( + "smoke", "polyglot-validation", "sdk-timers", "sdk-package-timers", + "sdk-application-package-timers", "sdk-activity-upgrades", "sdk-unversioned-timers", + "sdk-children", "sdk-updates", "sdk-namespaces", "sdk-search-attributes", + "sdk-activity-recovery", "sdk-external-effects", +) + + +def guarded_managers(source): + for operation, argument in instructions(source): + if operation != "RUN": + continue + for boundary in re.finditer(r"\bapt-get\s+(?:update|upgrade|install|purge)\b|\bplaywright\s+install\b[^;]*--with-deps", argument): + assert argument.rfind(GUARD, 0, boundary.start()) >= 0, "Package operation precedes the effective-client guard" + assert argument.startswith(GUARD) or argument.startswith("set -eux;"), "Guard refusal does not stop the RUN" + + +class ProducerPrerequisiteTests(unittest.TestCase): + def test_production_preserves_both_fixed_php_stages_and_all_late_guards(self): + source = (ROOT / "Dockerfile").read_text() + protected_php_stages(source) + guarded_managers(source) + + def test_every_direct_package_operation_requires_its_own_earlier_guard(self): + for path in ("Dockerfile", "polyglot/python_worker/Dockerfile"): + source = (ROOT / path).read_text() + for operation, argument in instructions(source): + if operation != "RUN" or not re.search(r"\bapt-get\s+(?:update|upgrade|install|purge)\b|--with-deps", argument): + continue + with self.subTest(path=path, run=argument[:60]): + changed = argument.replace(GUARD, "true") + self.assertNotEqual(argument, changed) + with self.assertRaisesRegex(AssertionError, "precedes"): + guarded_managers("RUN " + changed) + + def test_python_parent_and_source_copy_precede_the_first_run(self): + source = (ROOT / "polyglot/python_worker/Dockerfile").read_text() + self.assertEqual(next(instructions(source)), ("FROM", PYTHON_PARENT)) + copy = source.index("COPY .central-apt-source/docker/apt/") + first_run = source.index("\nRUN ") + self.assertLess(copy, first_run) + self.assertIn("RUN set -eux;", source) + self.assertLess(source.index(GUARD), source.index("apt-get update")) + self.assertLess(source.index("prepare-trust.py"), source.index("apt-get update")) + guarded_managers(source) + + def test_old_pip_has_only_an_offline_bootstrap_before_qualified_dependency_requests(self): + source = (ROOT / "polyglot/python_worker/Dockerfile").read_text() + producer = source.index("build-pip.py /tmp/sample-app-python-wheels") + offline = source.index("--no-index --no-deps --upgrade") + effective = source.index("verify-python-trust.py --pip-only") + dependencies = source.index('"fastavro==${FASTAVRO_PYTHON_VERSION}"') + self.assertLess(producer, offline) + self.assertLess(offline, effective) + self.assertLess(effective, dependencies) + self.assertNotIn("--seed", source) + self.assertIn('--cert "$SSL_CERT_FILE"', source) + + def test_initial_trust_export_does_not_claim_to_qualify_the_old_pip(self): + exporter = mock.Mock(return_value={"qualified": "system trust"}) + fake_module = types.SimpleNamespace(export_bundle=exporter) + path = ROOT / "docker/python/prepare-trust.py" + spec = importlib.util.spec_from_file_location("prepare_trust", path) + module = importlib.util.module_from_spec(spec) + with mock.patch.dict(sys.modules, {"export_trust": fake_module}): + spec.loader.exec_module(module) + with mock.patch.object(pathlib.Path, "mkdir"): + self.assertEqual(module.prepare(), {"qualified": "system trust"}) + self.assertEqual(exporter.call_args.kwargs["clients"], ("curl", "git", "python")) + self.assertEqual(exporter.call_args.args[0], pathlib.Path(module.ssl.get_default_verify_paths().cafile)) + + def test_actual_callers_check_out_the_same_public_source_without_credentials(self): + for name in WORKFLOWS: + with self.subTest(workflow=name): + source = (ROOT / ".github/workflows" / (name + ".yml")).read_text() + block = source.split("name: Check out the accepted central build source", 1)[1].split("\n - ", 1)[0] + self.assertIn("repository: durable-workflow/.github", block) + self.assertIn("ref: " + CENTRAL_COMMIT, block) + self.assertIn("path: .central-apt-source", block) + self.assertIn("persist-credentials: false", block) + preparation = (ROOT / "scripts/prepare-apt-build-source.sh").read_text() + self.assertIn(CENTRAL_COMMIT, preparation) + self.assertIn("credential.helper=", preparation) + self.assertIn("env -i", preparation) + self.assertIn("sha256sum --check --strict", preparation) + + +if __name__ == "__main__": + unittest.main() From 6b048bb8b524bcfd83b9b6cc30e4ef8f443f19bb Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:30:05 +0000 Subject: [PATCH 4/6] Preserve mounted browser outputs after dropping the application user --- .github/workflows/smoke.yml | 2 +- Dockerfile | 2 +- docker-compose.yml | 3 +++ tests/Unit/ScreenshotGeneratorContractTest.php | 1 + tests/ci/test_source_security_boundaries.py | 1 + 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 022025db..3ed323e5 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -53,7 +53,7 @@ jobs: test "$(docker image inspect "$image_id" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$SAMPLE_APP_COMMIT" docker image inspect "$image_id" --format 'image={{.Id}} platform={{.Os}}/{{.Architecture}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}' test "$(docker compose exec -T app id -u)" != 0 - docker compose exec -T app php -r '$path = tempnam("/app/storage", "permission-"); if ($path === false || file_put_contents($path, "non-root storage probe") === false || !unlink($path)) { exit(1); }' + docker compose exec -T app php -r 'foreach (["/app/storage", "/app/videos"] as $directory) { $path = tempnam($directory, "permission-"); if ($path === false || dirname($path) !== $directory || file_put_contents($path, "non-root storage probe") === false || !unlink($path)) { exit(1); } }' docker compose exec -T app /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh docker compose exec -T app node docker/verify-node-trust.mjs docker compose exec -T app node --test tests/Node/ProductionNodeTrustTest.mjs diff --git a/Dockerfile b/Dockerfile index 65a1fc57..978f6de8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,7 +106,7 @@ RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint RUN chmod +x /usr/local/bin/app-entrypoint \ - && mkdir -p "$HOME" "$PLAYWRIGHT_BROWSERS_PATH" \ + && mkdir -p /app/videos "$HOME" "$PLAYWRIGHT_BROWSERS_PATH" \ && chown -R www-data:www-data /app "$HOME" "$PLAYWRIGHT_BROWSERS_PATH" USER www-data diff --git a/docker-compose.yml b/docker-compose.yml index 63996fee..ae6606de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,9 @@ services: screenshots: build: *app-build + # This explicit local output tool keeps its prior host-bind write authority. + # App and worker services inherit the image's non-root runtime user. + user: root depends_on: app: condition: service_healthy diff --git a/tests/Unit/ScreenshotGeneratorContractTest.php b/tests/Unit/ScreenshotGeneratorContractTest.php index 8d55c567..4befbf42 100644 --- a/tests/Unit/ScreenshotGeneratorContractTest.php +++ b/tests/Unit/ScreenshotGeneratorContractTest.php @@ -83,6 +83,7 @@ public function test_documented_compose_entrypoint_uses_the_browser_safe_app_ali $this->assertContains('screenshots', $service['profiles'] ?? []); $this->assertContains('./screenshots:/app/screenshots', $service['volumes'] ?? []); $this->assertSame('/app/screenshots', $service['environment']['OUTPUT_DIR'] ?? null); + $this->assertSame('root', $service['user'] ?? null); $composeBaseUrl = $service['environment']['APP_URL'] ?? null; diff --git a/tests/ci/test_source_security_boundaries.py b/tests/ci/test_source_security_boundaries.py index 0a4730d7..2ad9b936 100644 --- a/tests/ci/test_source_security_boundaries.py +++ b/tests/ci/test_source_security_boundaries.py @@ -90,6 +90,7 @@ def test_nonroot_browser_and_application_state_have_writable_locations(self): final = re.split(r"^FROM ", source, flags=re.MULTILINE)[-1] self.assertIn("HOME=/home/www-data", final) self.assertIn("PLAYWRIGHT_BROWSERS_PATH=/ms-playwright", final) + self.assertIn('mkdir -p /app/videos "$HOME" "$PLAYWRIGHT_BROWSERS_PATH"', final) self.assertRegex(final, r'chown -R www-data:www-data /app "\$HOME" "\$PLAYWRIGHT_BROWSERS_PATH"') self.assertLess(final.index("PLAYWRIGHT_BROWSERS_PATH="), final.index("playwright install")) self.assertLess(final.index("chown -R"), final.index("USER www-data")) From 52869663a24a733345efecaebfdc105833e08746 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:55:20 +0000 Subject: [PATCH 5/6] Record both application and worker non-root runtime identities --- .github/workflows/smoke.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 3ed323e5..6735c1a7 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -52,7 +52,11 @@ jobs: test "$(docker image inspect "$image_id" --format '{{.Os}}/{{.Architecture}}')" = "$EXPECTED_PLATFORM" test "$(docker image inspect "$image_id" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$SAMPLE_APP_COMMIT" docker image inspect "$image_id" --format 'image={{.Id}} platform={{.Os}}/{{.Architecture}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}' - test "$(docker compose exec -T app id -u)" != 0 + for service in app worker; do + runtime_uid="$(docker compose exec -T "$service" id -u)" + printf '%s runtime uid=%s\n' "$service" "$runtime_uid" + test "$runtime_uid" != 0 + done docker compose exec -T app php -r 'foreach (["/app/storage", "/app/videos"] as $directory) { $path = tempnam($directory, "permission-"); if ($path === false || dirname($path) !== $directory || file_put_contents($path, "non-root storage probe") === false || !unlink($path)) { exit(1); } }' docker compose exec -T app /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh docker compose exec -T app node docker/verify-node-trust.mjs From 32bd4c5e1bcf3189f332f8810b789c59c365c342 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:01:30 +0000 Subject: [PATCH 6/6] Fix CI contract spelling and non-root build-source inspection --- .github/workflows/ci.yml | 2 +- Dockerfile | 1 + docker/python/prepare-trust.py | 4 ++-- polyglot/python_worker/Dockerfile | 5 +++-- scripts/prepare-apt-build-source.sh | 2 +- tests/Unit/DevcontainerImageContractTest.php | 2 +- tests/ci/test_source_security_boundaries.py | 12 ++++++++---- 7 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1763c299..a6843ad8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: python-version: '3.13' - name: Bootstrap patched pip trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@76f333804e68725059039fd54df241d40d855cd3 # clean-trust - - run: 'python -m pip install --only-binary=:all: PyYAML==6.0.2' + - run: 'python -m pip install --only-binary :all: PyYAML==6.0.2' - name: Require centrally approved immutable action commits run: >- python .central-action-policy/scripts/qualification_policy.py validate diff --git a/Dockerfile b/Dockerfile index 978f6de8..a458861f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ curl ffmpeg git libnspr4 libnss3 libpq-dev libzip-dev unzip \ && docker-php-ext-install pdo pdo_mysql pcntl zip bcmath \ && sh /usr/local/bin/install-phpredis \ + && docker-php-source delete \ && php /usr/local/share/sample-app/brotli/verify-runtime.php \ && php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ && rm -rf /var/lib/apt/lists/* diff --git a/docker/python/prepare-trust.py b/docker/python/prepare-trust.py index b02811e3..8d1aeafe 100644 --- a/docker/python/prepare-trust.py +++ b/docker/python/prepare-trust.py @@ -8,8 +8,8 @@ def prepare(): - state = Path("/tmp/sample-app-python-bootstrap") - state.mkdir(exist_ok=True) + state = Path("/usr/local/share/sample-app/python-bootstrap") + state.mkdir(mode=0o700, exist_ok=True) # This stage qualifies curl's system trust, not the parent's old pip. # pip remains offline until the existing wheel producer and pip guard pass. return export_bundle( diff --git a/polyglot/python_worker/Dockerfile b/polyglot/python_worker/Dockerfile index 6dc157d1..db5c9034 100644 --- a/polyglot/python_worker/Dockerfile +++ b/polyglot/python_worker/Dockerfile @@ -20,7 +20,7 @@ COPY .devcontainer/docker/verify-python-trust.py /usr/local/share/sample-app/ver RUN set -eux; \ /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh; \ python3 /usr/local/share/sample-app/python/prepare-trust.py; \ - set -a; . /tmp/sample-app-python-bootstrap/trust.env; set +a; \ + set -a; . /usr/local/share/sample-app/python-bootstrap/trust.env; set +a; \ test -n "$DURABLE_WORKFLOW_CLI_VERSION"; \ test -n "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION"; \ apt-get update \ @@ -32,12 +32,13 @@ RUN set -eux; \ && dw --version RUN set -eux; \ - set -a; . /tmp/sample-app-python-bootstrap/trust.env; set +a; \ + set -a; . /usr/local/share/sample-app/python-bootstrap/trust.env; set +a; \ python3 /usr/local/share/sample-app/python/build-pip.py /tmp/sample-app-python-wheels; \ python3 -I -m pip --isolated --disable-pip-version-check install \ --no-index --no-deps --upgrade /tmp/sample-app-python-wheels/*.whl; \ rm -rf /tmp/sample-app-python-wheels; \ python3 /usr/local/share/sample-app/verify-python-trust.py --pip-only; \ + python3 -I -c 'import json, platform, pip; from importlib.metadata import version; from pip._vendor import urllib3; print(json.dumps({"python": platform.python_version(), "pip": pip.__version__, "urllib3": urllib3.__version__, "setuptools": version("setuptools")}))'; \ python3 -I -m pip --isolated --disable-pip-version-check install \ --cert "$SSL_CERT_FILE" --no-cache-dir \ "fastavro==${FASTAVRO_PYTHON_VERSION}" \ diff --git a/scripts/prepare-apt-build-source.sh b/scripts/prepare-apt-build-source.sh index cea28928..a3a1e5da 100755 --- a/scripts/prepare-apt-build-source.sh +++ b/scripts/prepare-apt-build-source.sh @@ -25,7 +25,7 @@ if [[ ! -e "$source_dir" ]]; then mv -- "$source_temp" "$source_dir" trap - EXIT fi -test "$(git -C "$source_dir" rev-parse HEAD)" = "$expected_commit" +[[ "$(git -C "$source_dir" rev-parse HEAD)" == "$expected_commit" ]] cd "$source_dir" printf '%s\n' \ '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 docker/apt/require-fixed-bookworm-apt.sh' \ diff --git a/tests/Unit/DevcontainerImageContractTest.php b/tests/Unit/DevcontainerImageContractTest.php index d51006da..f79df3e0 100644 --- a/tests/Unit/DevcontainerImageContractTest.php +++ b/tests/Unit/DevcontainerImageContractTest.php @@ -80,7 +80,7 @@ public function test_image_checks_effective_trust_before_network_installation(): $lastInstall = strrpos($runtime, 'apt-get install'); $recheck = strrpos($runtime, $verifier); $nodeCheck = strpos($runtime, 'node /usr/local/share/sample-app/verify-node-trust.mjs'); - $cliDownload = strpos($runtime, 'curl -fsSL'); + $cliDownload = strpos($runtime, 'https://durable-workflow.com/install.sh'); foreach ([$firstCheck, $firstDownload, $lastInstall, $recheck, $nodeCheck, $cliDownload] as $position) { $this->assertIsInt($position); diff --git a/tests/ci/test_source_security_boundaries.py b/tests/ci/test_source_security_boundaries.py index 2ad9b936..bdd2c9b9 100644 --- a/tests/ci/test_source_security_boundaries.py +++ b/tests/ci/test_source_security_boundaries.py @@ -37,13 +37,16 @@ def require_binary_yaml(source): for command in commands: if command[0] in ("'", '"') and command[-1] == command[0]: command = command[1:-1] - assert "--only-binary=:all:" in shlex.split(command), "YAML installation allows source builds" + arguments = shlex.split(command) + assert "--only-binary" in arguments, "YAML installation allows source builds" + assert arguments[arguments.index("--only-binary") + 1] == ":all:", "YAML installation allows source builds" def require_nonroot_production(source): final = re.split(r"^FROM ", source, flags=re.MULTILINE)[-1] users = re.findall(r"^USER (\S+)", final, re.MULTILINE) - assert users and users[-1].split(":")[0] not in ("root", "0"), "Production runtime runs as root" + assert users, "Production runtime runs as root" + assert users[-1].split(":")[0] not in ("root", "0"), "Production runtime runs as root" class SourceSecurityBoundaryTests(unittest.TestCase): @@ -62,15 +65,16 @@ def test_either_download_protocol_restriction_cannot_be_removed(self): position = changed.index(option) del changed[position:position + 2] self.assertNotEqual(arguments, changed) + command = shlex.join(changed) with self.assertRaisesRegex(AssertionError, "HTTPS protocol"): - require_https_downloads(shlex.join(changed), url) + require_https_downloads(command, url) def test_action_policy_yaml_installation_does_not_execute_source_builds(self): require_binary_yaml((ROOT / ".github/workflows/ci.yml").read_text()) def test_binary_only_yaml_policy_cannot_be_removed(self): source = (ROOT / ".github/workflows/ci.yml").read_text() - changed = source.replace("--only-binary=:all:", "", 1) + changed = source.replace("--only-binary :all:", "", 1) self.assertNotEqual(source, changed) with self.assertRaisesRegex(AssertionError, "source builds"): require_binary_yaml(changed)