Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/phpunit-feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ jobs:
- name: Prove the image installs and checks the fixed Brotli decoder
run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py

- name: Prove the PHP image refuses an affected APT client before download
run: |
python3 -m unittest tests/Unit/Support/php_apt_test.py
python3 scripts/ci/verify-php-apt-source.py

- name: Check published failover image identity and recovery result gates
run: python3 -m unittest discover -s tests/Unit/Support -p 'single_region_failover*test.py'

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/public-boundary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ jobs:
scan:
name: Scan public boundary
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
persist-credentials: false

- name: Verify metadata scan failures
run: bash tests/Unit/Support/public_boundary_test.sh
Expand Down
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ RUN git clone --depth 1 --branch "${PHPREDIS_VERSION}" https://github.com/phpred
fi

# The fixed official Apache runtime is pinned for both release architectures.
FROM php@sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef AS base
FROM php@sha256:3614314e50a9b17a0c317dfd100c6346d8881d6565e713556b7a4e8eb3ec4505 AS base

# Bind the fixed APT parent and read the effective client before any package
# operation. The source proof is docker/apt/parent.v1.json. No self-upgrade.
COPY docker/apt/require-fixed-apt.sh /usr/local/lib/server-apt/require-fixed-apt.sh

# Both web and CLI roles require the fixed PHP runtime, including cached builds.
RUN php -r 'if (PHP_MAJOR_VERSION !== 8 || PHP_MINOR_VERSION !== 3 || PHP_VERSION_ID < 80336) { \
RUN /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh /usr/local/lib/server-apt/require-fixed-apt.sh \
&& php -r 'if (PHP_MAJOR_VERSION !== 8 || PHP_MINOR_VERSION !== 3 || PHP_VERSION_ID < 80336) { \
fwrite(STDERR, "Patched PHP 8.3.36 or later on the 8.3 line is required; found ".PHP_VERSION.PHP_EOL); \
exit(1); }'

Expand Down Expand Up @@ -100,7 +105,8 @@ COPY scripts/ci/WorkflowPackageAuthority.php scripts/ci/WorkflowPackageAuthority
COPY scripts/ci/resolve-workflow-package-authority.php scripts/ci/resolve-workflow-package-authority.php
COPY scripts/ci/prepare-release-workflow-composer-metadata.php scripts/ci/prepare-release-workflow-composer-metadata.php

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 \
&& rm -rf /var/lib/apt/lists/* \
&& eval "$(php scripts/ci/resolve-workflow-package-authority.php --format=shell)" \
Expand Down
28 changes: 28 additions & 0 deletions docker/apt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
The PHP production Dockerfile pins the official PHP 8.3.36 Apache/Trixie index
in `parent.v1.json`. Both selected platform inventories report APT and libapt
3.0.4, the Debian 13 fix for
[CVE-2026-109518](https://lists.debian.org/debian-security-announce/2026/msg00526.html).
The recorded child, config, companion and complete SPDX predicate hashes were
independently checked, including each statement's child subject. These public
metadata facts do not constitute an installed-image or build qualification.

Before the first command in both the base and production stages, the Dockerfile
runs `require-fixed-apt.sh` through an absolute, empty environment. It reads the
actual Debian release, architecture, fully installed APT/libapt records and
effective APT client version with fixed system commands. Unknown, missing,
mixed, unconfigured or below-floor observations refuse before a package
operation. The helper has no inputs, download or self-upgrade path.

Downstream source consumers can run `python3 scripts/ci/verify-php-apt-source.py`
against their selected Server checkout before a build. It checks the reviewed
parent/guard and complete Dockerfile bytes, literal parent and inherited
first-RUN ordering. A changed Dockerfile requires renewed review and binding.
It performs no network, image or package operation and reports no installed observation.
Consumers must additionally bind the exact accepted Server commit and verifier
bytes through their reviewed source authority. A passing source prerequisite
does not authorize a build, publication or runtime operation.

The source guard deliberately covers this production Dockerfile. The separate
feature-workflow parent and historical performance/conformance Dockerfiles have
their own subjects and acceptance records. The historical parents remain
unqualified for future networked APT until their own prerequisites are met.
35 changes: 35 additions & 0 deletions docker/apt/parent.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"schema": "durable-workflow.server-php-apt-parent.v1",
"image": "docker.io/library/php",
"php": "8.3.36-apache-trixie",
"os": "debian",
"release": "13",
"floor": "3.0.4",
"index": "sha256:3614314e50a9b17a0c317dfd100c6346d8881d6565e713556b7a4e8eb3ec4505",
"platforms": {
"linux/amd64": {
"child": "sha256:8732e645530351f2f16aa7dd7619a44dad614bcfde6b4eb994f9a228d24f9b98",
"config": "sha256:d86e74262d39379c3cd57031d8c65119dd5f317d1047aed19b8f4faa836bc267",
"attestation": "sha256:90503a9159222147e2e64757068b241eb4f0fc10b7d0f68dc948aefee753f44a",
"spdx": "sha256:22d23f0d84be8fe7dd7a09285b7e5d510a55fe6b79a42f226af0090daebad5de",
"spdx_bytes": 7219858,
"packages": {
"apt": "3.0.4",
"libapt-pkg7.0": "3.0.4"
}
},
"linux/arm64": {
"child": "sha256:ccdca92cf5c884a7d6d8d261d983516bcdbe9bec5055e9be869362a688960c6b",
"config": "sha256:32e2e59c07397349a251030797bf34c513f5a99532765f62f0e0dfb1753ece75",
"attestation": "sha256:7abf82b397d37e3955d90f9111ba2dd937595a825d17fc7c192f931f391a1bf2",
"spdx": "sha256:0b41c46fd80286de185d04e69d7e9b5dbc3875fed0bdc93d4ef5a04f6c40b125",
"spdx_bytes": 7317146,
"packages": {
"apt": "3.0.4",
"libapt-pkg7.0": "3.0.4"
}
}
},
"advisory": "https://lists.debian.org/debian-security-announce/2026/msg00526.html",
"source_only": true
}
33 changes: 33 additions & 0 deletions docker/apt/require-fixed-apt.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/sh
# Read-only first-RUN boundary for the exact Trixie production parent (amd64/arm64).
# The Dockerfile invokes this through an absolute, credential-free environment.
set -eu

refuse() {
printf '%s\n' 'Fixed APT prerequisite refused; no package operation was started.' >&2
exit 1
}

[ "$#" -eq 0 ] || refuse
[ "$(/usr/bin/sed -n 's/^ID=//p' /etc/os-release)" = debian ] || refuse
[ "$(/usr/bin/sed -n 's/^VERSION_ID=//p' /etc/os-release)" = '"13"' ] || refuse
architecture=$(/usr/bin/timeout 10 /usr/bin/dpkg --print-architecture) || refuse
case "$architecture" in amd64|arm64) ;; *) refuse ;; esac

installed_version() {
record=$(/usr/bin/timeout 10 /usr/bin/dpkg-query --show \
"--showformat=\${Status}\t\${Version}\n" "$1") || refuse

Check warning on line 19 in docker/apt/require-fixed-apt.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_server&issues=AaGs9rg38EEPFd-p_TSF&open=AaGs9rg38EEPFd-p_TSF&pullRequest=1038
prefix=$(printf 'install ok installed\t')
case "$record" in "$prefix"*) version=${record#"$prefix"} ;; *) refuse ;; esac
case "$version" in ''|[!0-9]*|*[!0-9A-Za-z.+:~-]*) refuse ;; esac

Check failure on line 22 in docker/apt/require-fixed-apt.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_server&issues=AaGs9rg38EEPFd-p_TSG&open=AaGs9rg38EEPFd-p_TSG&pullRequest=1038
[ "${#version}" -le 100 ] || refuse
printf '%s' "$version"
}

apt_version=$(installed_version apt) || refuse
libapt_version=$(installed_version libapt-pkg7.0) || refuse
[ "$apt_version" = "$libapt_version" ] || refuse
/usr/bin/timeout 10 /usr/bin/dpkg --compare-versions "$apt_version" ge 3.0.4 || refuse
client=$(/usr/bin/timeout 10 /usr/bin/apt-get --version) || refuse
[ "$(printf '%s\n' "$client" | /usr/bin/sed -n '1p')" = "apt $apt_version ($architecture)" ] || refuse
printf 'fixed-apt os=debian release=13 arch=%s apt=%s libapt-pkg7.0=%s\n' "$architecture" "$apt_version" "$libapt_version"
52 changes: 52 additions & 0 deletions scripts/ci/check-docker-workflow-isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,42 @@ def spans_overlap(first: tuple[int, int], second: tuple[int, int]) -> bool:
return first[0] < second[1] and second[0] < first[1]


def isolated_source_check(
root: Path,
program: str,
arguments: str,
) -> tuple[str, bool, tuple[int, int]] | None:
"""Model only closed isolated Python execution and shell diagnostics.

An executed Python helper remains a transitive inspection target. Shell
syntax/lint operands are data only for these exact argument positions;
accepting one occurrence must not exempt another invocation of the file.
"""

if program != "/usr/bin/env":
return None
match = re.fullmatch(
r"\s+-i PATH=/usr/bin:/bin LC_ALL=C "
r"(?P<tool>/usr/bin/python3 -I -S -B|/bin/sh -n|/usr/bin/shellcheck) "
r"(?P<path>(?:[\w.-]+/)*[\w.-]+\.(?:py|sh))\s*",
arguments,
)
if match is None:
return None
path = match.group("path")
target = root / path
if (
any(part in {".", ".."} for part in path.split("/"))
or not target.is_file()
or not target.resolve().is_relative_to(root.resolve())
):
return None
executes = match.group("tool").startswith("/usr/bin/python3 ")
if target.suffix != (".py" if executes else ".sh"):
return None
return path, executes, match.span("path")


def workflow_run_blocks(job_source: str) -> list[str]:
"""Return only shell source from run steps in one workflow job."""

Expand Down Expand Up @@ -2051,6 +2087,8 @@ def unclassified_dynamic_invocations(
raw_arguments = invocation.group("arguments")
if is_assignment_word(raw_program):
continue
if isolated_source_check(root, raw_program, raw_arguments) is not None:
continue

program, tokens, command_string = unwrap_command_launcher(
raw_program.strip("\"'"),
Expand Down Expand Up @@ -2147,6 +2185,20 @@ def workflow_script_references(
allowed_script_spans: list[tuple[int, int]] = []
violations = unclassified_dynamic_invocations(root, job_source)

for invocation in WORKFLOW_COMMAND_INVOCATION.finditer(invocation_source):
if starts_inside_shell_quote(invocation_source, invocation.start()):
continue
check = isolated_source_check(
root, invocation.group("program"), invocation.group("arguments")
)
if check is None:
continue
path, executes, span = check
if executes:
references.add(path)
offset = invocation.start("arguments")
allowed_script_spans.append((offset + span[0], offset + span[1]))

for reference in WORKFLOW_SCRIPT_REFERENCE.finditer(invocation_source):
if (
invocation_source[reference.start()] == "("
Expand Down
100 changes: 100 additions & 0 deletions scripts/ci/verify-php-apt-source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Read-only source prerequisite; never builds, fetches or installs anything."""
from __future__ import annotations

import argparse
import hashlib
import json
from pathlib import Path
import re


INDEX = "sha256:3614314e50a9b17a0c317dfd100c6346d8881d6565e713556b7a4e8eb3ec4505"
PROOF_SHA256 = "09078d7166cd57d8781ef344f5f7b2f69712ace54aa867d320d99954f039cd60"
GUARD_SHA256 = "082e4bcf3aebaf8da74658c2f15c4b6df259e0cb8f2b09e4e16a737e223a94a3"
DOCKERFILE_SHA256 = "d70f15b575b98f0cfd6c5b6abc95a9d53b9560b03aeed9c1996fec46d4d9d41a"
GUARD = "/usr/local/lib/server-apt/require-fixed-apt.sh"
PREFIX = "/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /bin/sh " + GUARD


def require(condition: bool, reason: str) -> None:
if not condition:
raise ValueError(reason)


def instructions(source: str):
logical = ""
for line in source.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
continued = line.endswith("\\")
logical += (line[:-1].rstrip() if continued else line) + " "
if not continued:
verb, _, value = logical.strip().partition(" ")
yield verb.upper(), value
logical = ""
require(not logical, "unterminated Dockerfile instruction")


def require_source_boundary(source: str, proof: bytes, guard: bytes) -> dict:

Check failure on line 40 in scripts/ci/verify-php-apt-source.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_server&issues=AaGs9reO8EEPFd-p_TSE&open=AaGs9reO8EEPFd-p_TSE&pullRequest=1038
# These hashes bind reviewed source inputs. They do not authenticate an
# arbitrary downloaded inventory, installed image or runtime observation.
require(hashlib.sha256(proof).hexdigest() == PROOF_SHA256, "unreviewed parent inventory")
require(hashlib.sha256(guard).hexdigest() == GUARD_SHA256, "unreviewed effective-client guard")
value = json.loads(proof)
stage, seen, first_run, copied = "", set(), set(), False
for verb, argument in instructions(source):
if verb == "FROM":
parts = argument.split()
require(len(parts) in (1, 3), "unsupported stage declaration")
if len(parts) == 3:
require(parts[1] == "AS" and parts[2] not in seen, "ambiguous stage")
stage = parts[2]
else:
stage = parts[0]
seen.add(stage)
if stage == "base":
require(parts[0] == "php@" + INDEX, "unproved PHP parent")
if stage == "production":
require(parts[0] == "base", "production must inherit the guarded base")
elif verb == "COPY" and argument == "docker/apt/require-fixed-apt.sh " + GUARD:
require(stage == "base" and stage not in first_run and not copied, "late or duplicate guard copy")
copied = True
elif verb == "RUN":
if stage not in first_run and stage in ("base", "production"):
require(copied and argument.startswith(PREFIX + " && "), "first RUN lacks the closed guard")
first_run.add(stage)
if re.search(r"\b(?:apt|apt-get|docker-php-ext-install|install-php-extensions)\b", argument):
require(stage in ("base", "production"), "unqualified package stage")
require({"base", "production"} <= first_run and copied, "missing guarded runtime stages")
require(hashlib.sha256(source.encode("utf-8")).hexdigest() == DOCKERFILE_SHA256,
"unreviewed Dockerfile bytes")
return value


def checked_file(root: Path, relative: str, limit: int) -> bytes:
path = root / relative
require(path.is_file() and not path.is_symlink() and path.resolve().is_relative_to(root),
"source path escapes checkout or is not a regular file")
with path.open("rb") as stream:
data = stream.read(limit + 1)
require(0 < len(data) <= limit, "source input bound")
return data


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
args = parser.parse_args()
root = args.root.resolve()
proof = require_source_boundary(
checked_file(root, "Dockerfile", 65536).decode("utf-8", errors="strict"),
checked_file(root, "docker/apt/parent.v1.json", 16384),
checked_file(root, "docker/apt/require-fixed-apt.sh", 8192))
print(json.dumps({"source_prerequisite": "pass", "index": proof["index"],
"platforms": sorted(proof["platforms"]), "installed_observation": False}))


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion tests/Unit/Support/composer-sinks.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
{
"type": "server-verify",
"line": "RUN server-verify-trust final \\",
"distance": 77
"distance": 78
}
]
},
Expand Down
Loading
Loading