From b136f28f2120e0f0ef24ab23218c7f7ad8243a16 Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 13:28:25 +0000 Subject: [PATCH 1/5] Split image tests into one file per feature area Move the checks in scripts/test-image.sh into scripts/tests/image/ with shared helpers in lib.sh and PHP fixtures as real files. Every test runs even after one fails, and TEST_ONLY runs a single area. No checks were added or changed. --- .github/workflows/service_lint.yml | 1 + AGENTS.md | 13 +- .../docs/1.getting-started/99.contributing.md | 30 +- scripts/test-image.sh | 358 ++++-------------- scripts/tests/image/.shellcheckrc | 3 + .../image/fixtures/public/finish-request.php | 4 + .../fixtures/public/frankenphp-worker.php | 6 + scripts/tests/image/fixtures/public/index.php | 1 + .../fixtures/public/storage/uploaded.php | 1 + scripts/tests/image/frankenphp.sh | 12 + scripts/tests/image/healthcheck.sh | 7 + scripts/tests/image/lib.sh | 128 +++++++ scripts/tests/image/octane.sh | 42 ++ scripts/tests/image/php.sh | 49 +++ scripts/tests/image/security.sh | 17 + scripts/tests/image/ssl.sh | 36 ++ scripts/tests/image/web-server.sh | 15 + 17 files changed, 433 insertions(+), 290 deletions(-) create mode 100644 scripts/tests/image/.shellcheckrc create mode 100644 scripts/tests/image/fixtures/public/finish-request.php create mode 100644 scripts/tests/image/fixtures/public/frankenphp-worker.php create mode 100644 scripts/tests/image/fixtures/public/index.php create mode 100644 scripts/tests/image/fixtures/public/storage/uploaded.php create mode 100644 scripts/tests/image/frankenphp.sh create mode 100644 scripts/tests/image/healthcheck.sh create mode 100644 scripts/tests/image/lib.sh create mode 100644 scripts/tests/image/octane.sh create mode 100644 scripts/tests/image/php.sh create mode 100644 scripts/tests/image/security.sh create mode 100644 scripts/tests/image/ssl.sh create mode 100644 scripts/tests/image/web-server.sh diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml index 380c0300..1e9b8012 100644 --- a/.github/workflows/service_lint.yml +++ b/.github/workflows/service_lint.yml @@ -43,6 +43,7 @@ jobs: --color=always --severity=warning \ scripts/*.sh \ scripts/tests/*.sh \ + scripts/tests/image/*.sh \ src/common/usr/local/bin/* \ src/s6/usr/local/bin/* \ .oss-scanner/run-variation \ diff --git a/AGENTS.md b/AGENTS.md index 10667d97..a420ecd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,18 +115,19 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - a new way for untrusted input to reach the image (a web server location, a healthcheck, a workflow trigger), a change to `SECURITY.md` scope, or a new intentional default the scanner should not report: `.oss-scanner/threat_model.md` After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`. -- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. -- `scripts/test-image.sh` checks that an image starts, runs as an unprivileged user, has no world-writable files or directories (outside sticky ones like `/tmp`), reports the expected PHP version, loads the default extensions, reaches a healthy HEALTHCHECK, and (for images with a web server) serves a mounted `index.php` through NGINX, Apache, or Caddy. Run it locally against any image before opening a PR that touches startup behavior. Keep the checks generic across variations: it detects the web server from the image's `*_HTTP_PORT` environment variables rather than the image name. +- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `scripts/tests/image/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. +- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `security.sh`, `healthcheck.sh`, `web-server.sh`, `ssl.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, served PHP files in `fixtures/public/`. Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. - `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. ## Verification -There is no automated test suite for image logic. To verify changes: +To verify image changes: 1. Build the affected variation locally with `scripts/dev.sh` -2. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` -3. For entrypoint script changes, test on both Debian and Alpine builds -4. Run `shellcheck` on any modified shell scripts when available +2. Run `bash scripts/test-image.sh ` against it, and add a test in `scripts/tests/image/` for new behavior +3. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh` +4. For entrypoint script changes, test on both Debian and Alpine builds +5. Run `shellcheck` on any modified shell scripts when available ## Key Design Decisions diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index c0f169dd..7dd73f3a 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -98,7 +98,7 @@ Every pull request starts with a `lint` group that runs before any image is buil docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \ - scripts/*.sh scripts/tests/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') + scripts/*.sh scripts/tests/*.sh scripts/tests/image/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile @@ -109,6 +109,34 @@ The script tests live in one file, `scripts/tests/run.sh`, as plain assertions o The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. +### Image tests +`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `security.sh`, `web-server.sh`, `ssl.sh`, `octane.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: + +```bash [Terminal] +bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx + +# Run one feature area +TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx +``` + +Every test runs even after one fails, and the summary lists each failure with its output. + +To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature, and `setup` starts one container that every test in the file shares: + +```bash [scripts/tests/image/web-server.sh] +requires healthcheck web_server + +setup() { + start_container +} + +test_serves_php() { + expect_body / "serversideup-php-ok:${php_version}" +} +``` + +A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. PHP files the web server should serve go in `scripts/tests/image/fixtures/public/`. + ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. diff --git a/scripts/test-image.sh b/scripts/test-image.sh index 130f071f..3043cad8 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,89 +1,45 @@ #!/usr/bin/env bash +# shellcheck disable=SC2034 # the image facts are read by the files in scripts/tests/image/ set -euo pipefail # Usage: test-image.sh [expected-php-version] # -# Runs a published or locally built image and checks the things a user would notice -# first: it starts, it runs as an unprivileged user, nothing is world-writable, PHP reports the expected version, -# the default extensions load, and images that ship a web server serve a PHP file -# through it. Works against any image reference. -# Commands go through the image's own entrypoint so every entrypoint.d script runs. -# The entrypoint prints a welcome banner first, so a command's own output is the last line. +# Runs a published or locally built image through the tests in scripts/tests/image/, +# one file per feature area. A file skips itself when the image lacks its feature, +# so the same tests run against every variation. Every test runs even after one +# fails, and the script exits non-zero if any did. +# +# Set TEST_ONLY to a file name without .sh (for example web-server) to run one area. image="${1:?Usage: test-image.sh [expected-php-version]}" expected_php="${2:-}" -health_timeout_seconds=90 -http_timeout_seconds=30 - -# Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile). -expected_extensions="opcache pcntl pdo_mysql pdo_pgsql redis zip" - -pass() { echo "โœ… $1"; } -fail() { echo "โŒ $1" >&2; exit 1; } -image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; } - -echo "๐Ÿ”Ž Testing $image" - -php_version=$(docker run --rm "$image" php -r 'echo PHP_VERSION;' | tail -n1) -# A release candidate tag like 8.6-rc reports a version like 8.6.0RC4 -if [[ "$expected_php" == *-rc ]] && [[ "$php_version" == "${expected_php%-rc}".*RC* ]]; then - expected_php="$php_version" -fi -if [ -n "$expected_php" ] && [ "$php_version" != "$expected_php" ]; then - fail "PHP reports $php_version, expected $expected_php" -fi -pass "PHP $php_version" +tests_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/tests/image" && pwd)" -uid=$(docker run --rm "$image" id -u | tail -n1) -[ "$uid" != "0" ] || fail "Container runs as root by default" -pass "Runs as unprivileged user (uid $uid)" +# shellcheck source=tests/image/lib.sh +source "$tests_dir/lib.sh" -# A COPY without --chmod gives existing destination directories the build context's modes. -# Sticky directories like /tmp are expected. -world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \ - 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print') -[ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable" -pass "No world-writable files or directories" - -loaded_extensions=$(docker run --rm "$image" php -r 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));' | tail -n1) -missing_extensions="" -for extension in $expected_extensions; do - case " $loaded_extensions " in - *" $extension "*) ;; - *"zend $extension "*) ;; - *) missing_extensions="$missing_extensions $extension" ;; - esac -done -[ -z "$missing_extensions" ] || fail "PHP extensions not loaded:$missing_extensions" -pass "Extensions loaded: $expected_extensions" - -# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few -# of the different value types (size, boolean, list) and confirm PHP sees them. -# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults. -ini_values=$(docker run --rm \ - --env PHP_MEMORY_LIMIT=512M \ - --env PHP_REALPATH_CACHE_SIZE=8M \ - --env PHP_SESSION_COOKIE_HTTPONLY=0 \ - --env PHP_DISABLE_FUNCTIONS=shell_exec \ - --env PHP_OPCACHE_ENABLE=1 \ - --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60 \ - "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' | tail -n1) -[ "$ini_values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values" -pass "Environment variables apply to php.ini" +test_label="serversideup-php-test=$$" +work_dir=$(mktemp -d) +cleanup() { + local containers + containers=$(docker ps --all --quiet --filter "label=$test_label") + # shellcheck disable=SC2086 # one container ID per word + [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true + rm -rf "$work_dir" +} +trap cleanup EXIT -# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server. -cli_opcache=$(docker run --rm --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0 \ - "$image" php -r 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' | tail -n1) -[ "$cli_opcache" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $cli_opcache" -pass "PHP_OPCACHE_ENABLE_CLI disables the CLI cache" +# The container runs unprivileged, so the mounted document root must be world readable. +web_dir="$work_dir/public" +cp -R "$tests_dir/fixtures/public" "$web_dir" +chmod -R u=rwX,go=rX "$web_dir" +php_version=$(run_php 'echo PHP_VERSION;') has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image") -if [ -z "$has_healthcheck" ]; then - pass "No HEALTHCHECK defined, skipping startup check" - exit 0 -fi +is_frankenphp=$(image_env CADDY_HTTP_PORT) -# Web images expose their HTTP port and document root as environment variables. +# Web images expose their HTTP port and document root as environment variables, and +# serve TLS on their HTTPS port once SSL_MODE is on. http_port="" web_root="" for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT CADDY_HTTP_PORT:CADDY_SERVER_ROOT; do @@ -95,229 +51,65 @@ for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT break fi done - -# The same images serve TLS on their HTTPS port once SSL_MODE is on, which is the only -# way to reach them over HTTP/2. https_port="" for variable in NGINX_HTTPS_PORT APACHE_HTTPS_PORT CADDY_HTTPS_PORT; do https_port=$(image_env "$variable") [ -z "$https_port" ] || break done -# Web images run with OPcache enabled so the health check and the served page -# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults. -run_args=(--detach --env PHP_OPCACHE_ENABLE=1) -if [ -n "$http_port" ]; then - # The container runs unprivileged, so the mounted document root must be world readable. - web_dir=$(mktemp -d) - chmod 755 "$web_dir" - echo ' "$web_dir/index.php" - chmod 644 "$web_dir/index.php" - mkdir -p "$web_dir/storage" - chmod 755 "$web_dir/storage" - echo ' "$web_dir/storage/uploaded.php" - chmod 644 "$web_dir/storage/uploaded.php" - # Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running - # terminate callbacks after it, so the web server has to survive a request that finishes - # before PHP does. - cat > "$web_dir/finish-request.php" <<'PHP' -/dev/null 2>&1 || true - done - [ -z "${web_dir:-}" ] || rm -rf "$web_dir" -} -trap cleanup EXIT - -dump_container_state() { - echo "--- container logs ---" >&2 - docker logs "$1" >&2 2>&1 || true - echo "--- last health check ---" >&2 - docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true -} - -start_container() { - container=$(docker run "${run_args[@]}" "$@" "$image") - containers+=("$container") - - status=starting - for _ in $(seq 1 "$health_timeout_seconds"); do - status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone") - case "$status" in - healthy) break ;; - unhealthy|gone) break ;; - esac - sleep 1 - done +failures_file="$work_dir/failures" +touch "$failures_file" - if [ "$status" != "healthy" ]; then - dump_container_state "$container" - fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" +report() { + local name="$1" status="$2" output="$3" + if [ "$status" -eq 0 ]; then + echo "โœ… $name" + return fi - [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://') - [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://') + echo "โŒ $name" + sed 's/^/ /' "$output" + echo "$area: $name" >> "$failures_file" } -# Retries until the response body matches, since the web server may still be warming up. -expect_body() { - path="$1" - expected="$2" - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true) - [ "$body" = "$expected" ] && return 0 - sleep 1 - done - dump_container_state "$container" - fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}" -} - -# Uploaded PHP files under /storage must never run, including through PATH_INFO -# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute. -expect_storage_blocked() { - for path in /storage/uploaded.php /storage/uploaded.php/anything; do - response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true) - [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}" - done -} - -# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log. -# Retries because the log line lands a moment after the response. -expect_authorization_redacted() { - curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true - for _ in $(seq 1 "$http_timeout_seconds"); do - logs=$(docker logs "$container" 2>&1) - case "$logs" in - *"octane-secret"*) fail "Access log contains the authorization query parameter" ;; - *"authorization=REDACTED"*) return 0 ;; - esac - sleep 1 - done - dump_container_state "$container" - fail "Access log does not redact the authorization query parameter" -} - -# PHP carrying on after fastcgi_finish_request() has to leave the server able to take the -# next request. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only over -# HTTP/2, so both protocol versions are worth checking. -expect_work_after_finish_request() { - for protocol in --http1.1 --http2; do - body="" - for _ in $(seq 1 "$http_timeout_seconds"); do - body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true) - [ "$body" = "finish-request-ok:${php_version}" ] && break - sleep 1 - done - if [ "$body" != "finish-request-ok:${php_version}" ]; then - dump_container_state "$container" - fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}" - fi - - # The crash lands after the response, so give the process a moment to fall over. - sleep 2 - status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone) - if [ "$status" != "running" ]; then - dump_container_state "$container" - fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)" - fi +# Runs in its own subshell so one file's containers and variables never reach the next. +# setup() runs in the file's shell so the tests can use the container it starts; an EXIT +# trap reports it when it fails. Each test runs in a subshell with errexit on, so a failed +# command ends that test only. +run_test_file() { + local file="$1" output="$work_dir/output" test_function status + # shellcheck source=/dev/null + source "$file" + if declare -F setup >/dev/null; then + exec 3>&1 + trap 'report setup 1 "$output" >&3' EXIT + setup >"$output" 2>&1 + trap - EXIT + fi + for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do + set +e + ( set -e; "$test_function" ) >"$output" 2>&1 + status=$? + set -e + report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" "$output" done } -# A detached container has no terminal, so Caddy's default format is json and its default -# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON. -expect_json_logs_on_stderr() { - stdout_logs=$(docker logs "$container" 2>/dev/null) - stderr_logs=$(docker logs "$container" 2>&1 >/dev/null) - case "$stderr_logs" in - *'"msg":"handled request"'*) ;; - *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;; - esac - case "$stdout_logs" in - *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;; - esac -} - -start_container -pass "Container became healthy" - -[ -n "$http_port" ] || exit 0 - -expect_body / "serversideup-php-ok:${php_version}" -pass "Web server serves PHP on port ${http_port}" - -expect_storage_blocked -pass "Web server blocks PHP execution under /storage" +echo "๐Ÿ”Ž Testing $image (PHP $php_version)" +for file in "$tests_dir"/*.sh; do + area=$(basename "$file" .sh) + [ "$area" != lib ] || continue + [ -z "${TEST_ONLY:-}" ] || [ "$area" = "$TEST_ONLY" ] || continue + echo + echo "$area" + set +e + ( set -e; run_test_file "$file" ) + set -e +done -# HTTP/2 needs TLS, so this runs against its own container with SSL_MODE=full, which -# generates a self-signed certificate and stops serving the plain HTTP port. The checks -# after it still expect the container the rest of the suite has been using. -if [ -n "$https_port" ]; then - default_container="$container" - default_host_port="$host_port" - start_container --env SSL_MODE=full - expect_work_after_finish_request - pass "Web server survives a request that finishes before PHP does, over HTTP/1.1 and HTTP/2" - container="$default_container" - host_port="$default_host_port" +echo +if [ -s "$failures_file" ]; then + echo "$(wc -l < "$failures_file" | tr -d ' ') test(s) failed:" >&2 + sed 's/^/ /' "$failures_file" >&2 + exit 1 fi - -# The rest applies to FrankenPHP only: Caddy's log defaults and Laravel Octane. -[ -n "$(image_env CADDY_HTTP_PORT)" ] || exit 0 - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Web server writes JSON logs to stderr and redacts the authorization query parameter" - -# Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from -# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch -# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration. -cat > "$web_dir/frankenphp-worker.php" <<'PHP' -}" -pass "Octane mode exposes the Caddy admin API on Octane's admin port" - -expect_authorization_redacted -expect_json_logs_on_stderr -pass "Octane mode writes JSON logs to stderr and redacts the authorization query parameter" +echo "All tests passed" diff --git a/scripts/tests/image/.shellcheckrc b/scripts/tests/image/.shellcheckrc new file mode 100644 index 00000000..f8238048 --- /dev/null +++ b/scripts/tests/image/.shellcheckrc @@ -0,0 +1,3 @@ +# The test files read and set variables that scripts/test-image.sh and lib.sh share +# with them (image, php_version, container, ...), which ShellCheck can't see across files. +disable=SC2034,SC2154 diff --git a/scripts/tests/image/fixtures/public/finish-request.php b/scripts/tests/image/fixtures/public/finish-request.php new file mode 100644 index 00000000..ad27d392 --- /dev/null +++ b/scripts/tests/image/fixtures/public/finish-request.php @@ -0,0 +1,4 @@ +&2; exit 1; } +image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; } + +# Usage: run_php [docker run options] +run_php() { + local code="$1" + shift + docker run --rm "$@" "$image" php -r "$code" | tail -n1 +} + +# Skips the rest of the test file when the image lacks one of the named features. +requires() { + local feature missing + for feature in "$@"; do + case "$feature" in + healthcheck) [ -n "$has_healthcheck" ] || missing="no HEALTHCHECK" ;; + web_server) [ -n "$http_port" ] || missing="no web server" ;; + https) [ -n "$https_port" ] || missing="no HTTPS port" ;; + frankenphp) [ -n "$is_frankenphp" ] || missing="not FrankenPHP" ;; + *) fail "Unknown requirement: $feature" ;; + esac + if [ -n "${missing:-}" ]; then + echo "โญ๏ธ skipped: $missing" + exit 0 + fi + done +} + +dump_container_state() { + echo "--- container logs ---" >&2 + docker logs "$1" >&2 2>&1 || true + echo "--- last health check ---" >&2 + docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true +} + +# Starts the image in the background and waits for its HEALTHCHECK. Web images get the +# fixtures mounted as their document root and their ports published on random host ports. +# Web images run with OPcache enabled so the health check and the served page +# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults. +# Sets container, host_port, and https_host_port. +start_container() { + local args=(--detach --label "$test_label" --env PHP_OPCACHE_ENABLE=1) status=starting + if [ -n "$http_port" ]; then + args+=(--publish "127.0.0.1::${http_port}" --volume "$web_dir:$web_root:ro") + [ -z "$https_port" ] || args+=(--publish "127.0.0.1::${https_port}") + fi + container=$(docker run "${args[@]}" "$@" "$image") + + for _ in $(seq 1 "$health_timeout_seconds"); do + status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone") + case "$status" in + healthy|unhealthy|gone) break ;; + esac + sleep 1 + done + + if [ "$status" != "healthy" ]; then + dump_container_state "$container" + fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)" + fi + [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://') + [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://') +} + +# Retries until the response body matches, since the web server may still be warming up. +expect_body() { + local path="$1" expected="$2" body="" + for _ in $(seq 1 "$http_timeout_seconds"); do + body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true) + [ "$body" = "$expected" ] && return 0 + sleep 1 + done + dump_container_state "$container" + fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}" +} + +# Uploaded PHP files under /storage must never run, including through PATH_INFO +# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute. +expect_storage_blocked() { + local path response + for path in /storage/uploaded.php /storage/uploaded.php/anything; do + response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true) + [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}" + done +} + +# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log. +# Retries because the log line lands a moment after the response. +expect_authorization_redacted() { + local logs + curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true + for _ in $(seq 1 "$http_timeout_seconds"); do + logs=$(docker logs "$container" 2>&1) + case "$logs" in + *"octane-secret"*) fail "Access log contains the authorization query parameter" ;; + *"authorization=REDACTED"*) return 0 ;; + esac + sleep 1 + done + dump_container_state "$container" + fail "Access log does not redact the authorization query parameter" +} + +# A detached container has no terminal, so Caddy's default format is json and its default +# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON. +# Run it after a request has been logged. +expect_json_logs_on_stderr() { + local stdout_logs stderr_logs + stdout_logs=$(docker logs "$container" 2>/dev/null) + stderr_logs=$(docker logs "$container" 2>&1 >/dev/null) + case "$stderr_logs" in + *'"msg":"handled request"'*) ;; + *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;; + esac + case "$stdout_logs" in + *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;; + esac +} diff --git a/scripts/tests/image/octane.sh b/scripts/tests/image/octane.sh new file mode 100644 index 00000000..b0907989 --- /dev/null +++ b/scripts/tests/image/octane.sh @@ -0,0 +1,42 @@ +# shellcheck shell=bash +# Laravel Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from +# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch +# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration. +requires healthcheck web_server frankenphp + +# A custom CADDY_SERVER_ROOT must move the worker script along with the document root. +octane_root=/var/www/octane/public + +setup() { + start_container \ + --env LARAVEL_OCTANE=1 \ + --env "CADDY_SERVER_ROOT=$octane_root" \ + --volume "$web_dir:$octane_root:ro" \ + --env CADDY_SERVER_ADMIN_HOST=localhost \ + --env CADDY_SERVER_ADMIN_PORT=2099 \ + --env "CADDY_SERVER_WORKER_DIRECTIVE=num 2" \ + --env "CADDY_SERVER_WATCH_DIRECTIVES=watch $octane_root" \ + --env "CADDY_GLOBAL_OPTIONS=auto_https disable_redirects" \ + --env CADDY_SERVER_EXTRA_DIRECTIVES= +} + +test_routes_requests_to_frankenphp_worker() { + expect_body / "octane-worker-ok:${php_version}" + expect_body /some/route "octane-worker-ok:${php_version}" +} + +test_blocks_php_under_storage() { + expect_storage_blocked +} + +# octane:status, octane:reload, and octane:stop use the Caddy admin API on Octane's port. +test_exposes_the_caddy_admin_api_on_octanes_port() { + local response + response=$(docker exec "$container" curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' http://localhost:2099/config/apps/frankenphp || true) + [ "$response" = "200" ] || fail "Caddy admin API is not reachable on Octane's admin port, got ${response:-}" +} + +test_writes_json_logs_to_stderr_and_redacts_authorization() { + expect_authorization_redacted + expect_json_logs_on_stderr +} diff --git a/scripts/tests/image/php.sh b/scripts/tests/image/php.sh new file mode 100644 index 00000000..50cf5a68 --- /dev/null +++ b/scripts/tests/image/php.sh @@ -0,0 +1,49 @@ +# shellcheck shell=bash +# PHP itself: the version, the default extensions, and php.ini settings from environment variables. + +test_reports_the_expected_php_version() { + local expected="$expected_php" + [ -n "$expected" ] || return 0 + # A release candidate tag like 8.6-rc reports a version like 8.6.0RC4 + if [[ "$expected" == *-rc ]] && [[ "$php_version" == "${expected%-rc}".*RC* ]]; then + expected="$php_version" + fi + [ "$php_version" = "$expected" ] || fail "PHP reports $php_version, expected $expected" +} + +test_loads_the_default_extensions() { + # Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile). + local expected="opcache pcntl pdo_mysql pdo_pgsql redis zip" loaded missing="" extension + loaded=$(run_php 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));') + for extension in $expected; do + case " $loaded " in + *" $extension "*) ;; + *"zend $extension "*) ;; + *) missing="$missing $extension" ;; + esac + done + [ -z "$missing" ] || fail "PHP extensions not loaded:$missing" +} + +# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few +# of the different value types (size, boolean, list) and confirm PHP sees them. +# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults. +test_applies_environment_variables_to_php_ini() { + local values + values=$(run_php 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' \ + --env PHP_MEMORY_LIMIT=512M \ + --env PHP_REALPATH_CACHE_SIZE=8M \ + --env PHP_SESSION_COOKIE_HTTPONLY=0 \ + --env PHP_DISABLE_FUNCTIONS=shell_exec \ + --env PHP_OPCACHE_ENABLE=1 \ + --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60) + [ "$values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $values" +} + +# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server. +test_disables_opcache_for_the_cli() { + local status + status=$(run_php 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' \ + --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0) + [ "$status" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $status" +} diff --git a/scripts/tests/image/security.sh b/scripts/tests/image/security.sh new file mode 100644 index 00000000..c0d3520b --- /dev/null +++ b/scripts/tests/image/security.sh @@ -0,0 +1,17 @@ +# shellcheck shell=bash +# Defaults that keep a compromised app from taking over the container. + +test_runs_as_an_unprivileged_user() { + local uid + uid=$(docker run --rm "$image" id -u | tail -n1) + [ "$uid" != "0" ] || fail "Container runs as root by default" +} + +# A COPY without --chmod gives existing destination directories the build context's modes. +# Sticky directories like /tmp are expected. +test_has_no_world_writable_files_or_directories() { + local world_writable + world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \ + 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print') + [ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable" +} diff --git a/scripts/tests/image/ssl.sh b/scripts/tests/image/ssl.sh new file mode 100644 index 00000000..82733961 --- /dev/null +++ b/scripts/tests/image/ssl.sh @@ -0,0 +1,36 @@ +# shellcheck shell=bash +# SSL_MODE=full generates a self-signed certificate and stops serving the plain HTTP port. +# It is also the only way to reach the web server over HTTP/2. +requires healthcheck web_server https + +setup() { + start_container --env SSL_MODE=full +} + +# Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running +# terminate callbacks after it, so the web server has to survive a request that finishes +# before PHP does. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only +# over HTTP/2, so both protocol versions are worth checking. +test_survives_requests_that_finish_before_php_over_http1_and_http2() { + local protocol body status + for protocol in --http1.1 --http2; do + body="" + for _ in $(seq 1 "$http_timeout_seconds"); do + body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true) + [ "$body" = "finish-request-ok:${php_version}" ] && break + sleep 1 + done + if [ "$body" != "finish-request-ok:${php_version}" ]; then + dump_container_state "$container" + fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}" + fi + + # The crash lands after the response, so give the process a moment to fall over. + sleep 2 + status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone) + if [ "$status" != "running" ]; then + dump_container_state "$container" + fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)" + fi + done +} diff --git a/scripts/tests/image/web-server.sh b/scripts/tests/image/web-server.sh new file mode 100644 index 00000000..84e65b17 --- /dev/null +++ b/scripts/tests/image/web-server.sh @@ -0,0 +1,15 @@ +# shellcheck shell=bash +# NGINX, Apache, or Caddy serving the mounted fixtures in fixtures/public. +requires healthcheck web_server + +setup() { + start_container +} + +test_serves_php() { + expect_body / "serversideup-php-ok:${php_version}" +} + +test_blocks_php_under_storage() { + expect_storage_blocked +} From f75b1e6785f34615a91462832b42240cb958f61b Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Thu, 29 Oct 2026 13:40:39 +0000 Subject: [PATCH 2/5] Test Laravel automations, startup scripts, set-id, and SSL modes Add laravel-automations.sh (a stand-in artisan records each command), entrypoint.sh, permissions.sh, and SSL mixed mode and HTTPS redirect tests. requires now also skips a single test, and build_image builds a test image from the one under test. --- AGENTS.md | 2 +- .../docs/1.getting-started/99.contributing.md | 8 +- scripts/test-image.sh | 63 +++++++++------ scripts/tests/image/entrypoint.sh | 38 +++++++++ .../image/fixtures/entrypoint.d/10-second.sh | 3 + .../image/fixtures/entrypoint.d/100-third.sh | 1 + .../image/fixtures/entrypoint.d/2-first.sh | 1 + .../image/fixtures/entrypoint.d/20-fails.sh | 2 + scripts/tests/image/fixtures/laravel/artisan | 11 +++ .../fixtures/laravel/vendor/autoload.php | 1 + scripts/tests/image/laravel-automations.sh | 80 +++++++++++++++++++ scripts/tests/image/lib.sh | 26 ++++-- scripts/tests/image/permissions.sh | 27 +++++++ scripts/tests/image/ssl.sh | 17 +++- 14 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 scripts/tests/image/entrypoint.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/10-second.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/100-third.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/2-first.sh create mode 100644 scripts/tests/image/fixtures/entrypoint.d/20-fails.sh create mode 100644 scripts/tests/image/fixtures/laravel/artisan create mode 100644 scripts/tests/image/fixtures/laravel/vendor/autoload.php create mode 100644 scripts/tests/image/laravel-automations.sh create mode 100644 scripts/tests/image/permissions.sh diff --git a/AGENTS.md b/AGENTS.md index a420ecd6..b9ce5adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ When modifying the version pipeline, the base config (`php-versions-base-config. After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`. - Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `scripts/tests/image/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs. -- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `security.sh`, `healthcheck.sh`, `web-server.sh`, `ssl.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, served PHP files in `fixtures/public/`. Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. +- `scripts/test-image.sh [php-version]` runs the image tests in `scripts/tests/image/`: one file per feature area (`php.sh`, `security.sh`, `entrypoint.sh`, `laravel-automations.sh`, `permissions.sh`, `healthcheck.sh`, `web-server.sh`, `ssl.sh`, `frankenphp.sh`, `octane.sh`), each `test_*` function one test, shared helpers and image facts in `lib.sh`, mounted files in `fixtures/` (`laravel/artisan` records commands instead of running Laravel). Every test runs even after one fails. Add a test to the file for its area, or a new file for a new area; never add checks to `scripts/test-image.sh` itself. Keep tests generic across variations: a file or test declares what it needs with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`), detected from the image's environment variables rather than its name. Run it locally against any image before opening a PR that touches startup behavior; `TEST_ONLY=` runs one area. - Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment. - `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. It also handles runs that end `cancelled` because a Depot runner never connected, but skips a run cancelled by hand (no failed jobs). It finds runs by workflow file, because `workflow_run.name` is the run's `run-name` title. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot. diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index 7dd73f3a..23178c98 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -110,7 +110,7 @@ The script tests live in one file, `scripts/tests/run.sh`, as plain assertions o The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. ### Image tests -`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `security.sh`, `web-server.sh`, `ssl.sh`, `octane.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: +`scripts/test-image.sh` runs an image through the tests in `scripts/tests/image/`, one file per feature area (`php.sh`, `entrypoint.sh`, `laravel-automations.sh`, `web-server.sh`, `ssl.sh`, ...). CI runs it against every image before anything is published. Run it against any image you built or pulled: ```bash [Terminal] bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx @@ -121,7 +121,7 @@ TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.4-fpm-nginx Every test runs even after one fails, and the summary lists each failure with its output. -To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature, and `setup` starts one container that every test in the file shares: +To add a test, add a `test_` function to the file for its feature area, or create a new file in `scripts/tests/image/`. Each function is one test, named after the function. `requires` skips the file when the image doesn't have the feature (or just the test, when you call it inside one), and `setup` starts one container that every test in the file shares: ```bash [scripts/tests/image/web-server.sh] requires healthcheck web_server @@ -135,7 +135,7 @@ test_serves_php() { } ``` -A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. PHP files the web server should serve go in `scripts/tests/image/fixtures/public/`. +A test passes when it returns and fails when a command fails or it calls `fail "message"`. The helpers (`start_container`, `run_php`, `expect_body`, ...) and the image facts (`php_version`, `http_port`, `container`, ...) live in `scripts/tests/image/lib.sh`. Files the tests mount go in `scripts/tests/image/fixtures/`: `public/` is the web server's document root, `laravel/` is a stand-in Laravel app whose `artisan` records each command it receives, and `entrypoint.d/` holds sample startup scripts. ### What happens when you open a pull request Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build. @@ -143,7 +143,7 @@ Every pull request that touches `src/`, `scripts/`, or the workflows builds the - **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64. Fork builds can't save the compiled PHP base images, so they start from the official PHP images on Docker Hub instead. In the hours after a new PHP release, before Docker Hub has it, fork builds fail. Changes to how PHP is compiled are only tested when a maintainer runs the build for your PR. - **Pull requests from this repository** go through the same three phases as a release: build, test, publish. Images are published to `serversideup/php-dev` with your PR number as the tag prefix (for example `serversideup/php-dev:699-8.4-fpm-nginx-alpine`) only after the image tests pass. A comment on the PR shows the status and the pull commands. - **Every run ends with a summary table** listing each image with its compressed size per architecture, so a size regression is visible before merge. -- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, and serves a PHP file through its web server. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. +- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, serves PHP through its web server in every SSL mode, and runs startup scripts and Laravel automations the way the docs describe. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`. - **Transient failures heal themselves.** If a few jobs fail because a registry or mirror hiccupped, CI re-runs just those jobs once. If a release or weekly rebuild is still red after that, CI opens an issue for the maintainers with the failed jobs and their errors. - **Maintainers** can publish a fork's images for testing by running the "Docker Publish (PR Images)" workflow manually with the PR number. diff --git a/scripts/test-image.sh b/scripts/test-image.sh index 3043cad8..0252034a 100755 --- a/scripts/test-image.sh +++ b/scripts/test-image.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2034 # the image facts are read by the files in scripts/tests/image/ +# shellcheck disable=SC2034,SC2154 # variables are shared with scripts/tests/image/lib.sh and the test files set -euo pipefail # Usage: test-image.sh [expected-php-version] @@ -21,18 +21,22 @@ source "$tests_dir/lib.sh" test_label="serversideup-php-test=$$" work_dir=$(mktemp -d) cleanup() { - local containers + local containers images containers=$(docker ps --all --quiet --filter "label=$test_label") - # shellcheck disable=SC2086 # one container ID per word + images=$(docker images --quiet --filter "label=$test_label" | sort -u) + # shellcheck disable=SC2086 # one ID per word [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true + # shellcheck disable=SC2086 + [ -z "$images" ] || docker rmi --force $images >/dev/null 2>&1 || true rm -rf "$work_dir" } trap cleanup EXIT -# The container runs unprivileged, so the mounted document root must be world readable. -web_dir="$work_dir/public" -cp -R "$tests_dir/fixtures/public" "$web_dir" -chmod -R u=rwX,go=rX "$web_dir" +# The container runs unprivileged, so mounted fixtures must be world readable. +fixtures_dir="$work_dir/fixtures" +cp -R "$tests_dir/fixtures" "$fixtures_dir" +chmod -R u=rwX,go=rX "$fixtures_dir" +web_dir="$fixtures_dir/public" php_version=$(run_php 'echo PHP_VERSION;') has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image") @@ -60,37 +64,50 @@ done failures_file="$work_dir/failures" touch "$failures_file" +output="$work_dir/output" + report() { - local name="$1" status="$2" output="$3" - if [ "$status" -eq 0 ]; then - echo "โœ… $name" - return + local name="$1" status="$2" + case "$status" in + 0) echo "โœ… $name" ;; + "$skip_status") echo "โญ๏ธ $name, $(tail -n1 "$output")" ;; + *) + echo "โŒ $name" + sed 's/^/ /' "$output" + echo "$area: $name" >> "$failures_file" + ;; + esac +} + +# A file stops early when requires skips it or setup fails. +report_file_stopped() { + if [ "$1" -eq "$skip_status" ]; then + echo "โญ๏ธ $(tail -n1 "$output")" + else + report setup "$1" fi - echo "โŒ $name" - sed 's/^/ /' "$output" - echo "$area: $name" >> "$failures_file" } # Runs in its own subshell so one file's containers and variables never reach the next. -# setup() runs in the file's shell so the tests can use the container it starts; an EXIT -# trap reports it when it fails. Each test runs in a subshell with errexit on, so a failed -# command ends that test only. +# The file and its setup() run in that shell so the tests can use the container setup +# starts; an EXIT trap reports them when they stop it. Each test runs in a subshell with +# errexit on, so a failed command ends that test only. run_test_file() { - local file="$1" output="$work_dir/output" test_function status + local file="$1" test_function status + exec 3>&1 + trap 'report_file_stopped $? >&3' EXIT # shellcheck source=/dev/null - source "$file" + source "$file" >"$output" 2>&1 if declare -F setup >/dev/null; then - exec 3>&1 - trap 'report setup 1 "$output" >&3' EXIT setup >"$output" 2>&1 - trap - EXIT fi + trap - EXIT for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do set +e ( set -e; "$test_function" ) >"$output" 2>&1 status=$? set -e - report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" "$output" + report "$(echo "${test_function#test_}" | tr _ ' ')" "$status" done } diff --git a/scripts/tests/image/entrypoint.sh b/scripts/tests/image/entrypoint.sh new file mode 100644 index 00000000..c57c80cd --- /dev/null +++ b/scripts/tests/image/entrypoint.sh @@ -0,0 +1,38 @@ +# shellcheck shell=bash +# docker-php-serversideup-entrypoint: the scripts in /etc/entrypoint.d, then the command. + +# Usage: run_entrypoint + + diff --git a/docs/content/docs/1.getting-started/7.upgrade-guide.md b/docs/content/docs/1.getting-started/7.upgrade-guide.md index 7172b453..3fe2c126 100644 --- a/docs/content/docs/1.getting-started/7.upgrade-guide.md +++ b/docs/content/docs/1.getting-started/7.upgrade-guide.md @@ -20,7 +20,9 @@ Regardless if you are choosing to use floating tags or pinned releases, it is hi All source code merges into the `main` branch. A pre-release is created for beta testing. After internal and community testing, we [publish a release on GitHub](https://github.com/serversideup/docker-php/releases) with a detailed changelog. Publishing a release triggers a production build that pushes a new set of tags to Docker Hub and GitHub Packages. -Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest upstream PHP and operating system security patches even when we haven't cut a release that week. +Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest operating system security patches even when we haven't cut a release that week. + +New PHP patches don't wait for Tuesday. Every hour, a workflow checks php.net for active PHP releases and starts a production build when a patch has no image yet, so a new patch is usually published within about an hour of php.net listing it, on any day. Like the weekly rebuild, these builds only update floating tags such as `8.5-cli` and `8.5.13-cli`. Version-pinned `-vX.Y.Z` tags are only created when we publish a GitHub release. ### Floating tags vs. version-pinned tags diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md index ce6fe791..7fcc73ae 100644 --- a/docs/content/docs/1.getting-started/99.contributing.md +++ b/docs/content/docs/1.getting-started/99.contributing.md @@ -92,11 +92,13 @@ We use GitHub Actions exclusively to publish all of our releases. If the image e See `.github/workflows/action_publish-images-beta.yml` for an example of how we publish our beta images. ### Linting -Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push: +Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) and [zizmor](https://docs.zizmor.sh/) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push: ```bash [Terminal] docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color +docker run --rm -v "$PWD:/repo:ro" -w /repo ghcr.io/zizmorcore/zizmor:latest .github/workflows + docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \ scripts/*.sh scripts/tests/*.sh scripts/tests/image/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh') @@ -107,6 +109,8 @@ bash scripts/tests/run.sh The script tests live in one file, `scripts/tests/run.sh`, as plain assertions over a small set of sample images. CI runs the same file on a GitHub runner, whose `jq` and `yq` versions can differ from your machine, so a green run there is the one that counts. +zizmor audits the workflows for security problems such as actions that aren't pinned to a commit SHA, template injection, and broad token permissions. Every action is pinned to a full commit SHA with its version in a comment, such as `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`, and Renovate keeps the pins current. Pin any action you add the same way. In CI, zizmor also gets a read-only token so it can check that each pinned SHA belongs to its repository. To run that check locally, set `GH_TOKEN` and add `-e GH_TOKEN` to the command. When you disable a zizmor rule, record it and the reason in `.github/zizmor.yml`. + The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`. ### Image tests diff --git a/docs/content/docs/5.guide/7.verifying-images.md b/docs/content/docs/5.guide/7.verifying-images.md new file mode 100644 index 00000000..d96ed615 --- /dev/null +++ b/docs/content/docs/5.guide/7.verifying-images.md @@ -0,0 +1,98 @@ +--- +head.title: 'Verifying Images - Docker PHP - Server Side Up' +description: 'Check that a serversideup/php image was built and published by our GitHub Actions workflows, and see what is inside it.' +layout: docs +title: Verifying images +--- + +::lead-p +Every image we publish is signed and carries a software bill of materials (SBOM) and build provenance. Use them to confirm that the image you pulled came from our GitHub Actions workflows and to see exactly what went into it. +:: + +## Why verify an image +A tag is only a pointer. Whoever controls it can point `8.5-cli` at a different image. A signature check proves the image was published by the `serversideup/docker-php` repository's own publish workflow, from `main` or a release tag. The SBOM and provenance tell you what is in the image and how it was built, which is what most supply-chain policies and audits ask for. + +::note +Images published before we started signing have provenance but no SBOM or signature. Version-pinned tags such as `8.4-fpm-nginx-v4.3.5` are never rebuilt. Older ones will always fail the signature check. Floating tags like `8.5-cli` are signed from their next rebuild on. +:: + +## Verify the signature +Install cosign by following the [Sigstore installation guide](https://docs.sigstore.dev/cosign/system_config/installation/), then run: + +```bash [Terminal] +cosign verify serversideup/php:8.5-cli \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_publish-images\.yml@refs/(heads/main|tags/v.+)$' +``` + +The two flags say who you trust. The issuer is GitHub Actions. The identity is our publish workflow running from `main` or a `v` release tag. cosign exits with an error if the image has no signature from that identity. + +Signing is keyless: there is no public key to download. Each signature is tied to the workflow run that made it through GitHub's OIDC token and recorded in Sigstore's public transparency log. + +Docker Hub and GitHub Packages each hold their own signature. The same command works for `ghcr.io/serversideup/php:8.5-cli`. The signature covers the image index, which references the image for every platform along with its SBOM and provenance. One check covers all of them. + +## Read the SBOM +The SBOM is an [SPDX](https://spdx.dev/) document that lists the packages in the image. Read it for one platform with Docker Buildx: + +```bash [Terminal] +docker buildx imagetools inspect serversideup/php:8.5-cli \ + --format '{{ json (index .SBOM "linux/amd64").SPDX }}' +``` + +Use `linux/arm64` for the arm64 image. On an image published before SBOMs were added, the command prints `null`. + +## Read the build provenance +The provenance is a [SLSA](https://slsa.dev/) record of how the image was built: the source commit, the Dockerfile, the build arguments, and the base images. We publish it in full (`mode=max`): + +```bash [Terminal] +docker buildx imagetools inspect serversideup/php:8.5-cli \ + --format '{{ json (index .Provenance "linux/amd64").SLSA }}' +``` + +To see only the commit and Dockerfile an image was built from, read `invocation.configSource`: + +```bash [Terminal] +docker buildx imagetools inspect serversideup/php:8.5-cli \ + --format '{{ json (index .Provenance "linux/amd64").SLSA.invocation.configSource }}' +``` + +```json [Output] +{ + "digest": { + "sha1": "af1462e3abf8c67ff3fb78141255adc33de5bf2c" + }, + "entryPoint": "src/variations/cli/Dockerfile", + "uri": "https://github.com/serversideup/docker-php.git#af1462e3abf8c67ff3fb78141255adc33de5bf2c" +} +``` + +## Verify the vulnerability scan +After every production build, we scan each published image and attach the results to it as a signed `vuln` attestation. The scan workflow signs it with its own identity. Verify it against that workflow: + +```bash [Terminal] +cosign verify-attestation serversideup/php:8.5-cli \ + --type vuln \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_scan-images\.yml@refs/(heads/main|tags/v.+)$' +``` + +cosign prints one JSON line per attestation, with the scan in a base64 `payload`. To see the counts per severity and how many of them have a fix: + +```bash [Terminal] +cosign verify-attestation serversideup/php:8.5-cli \ + --type vuln \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_scan-images\.yml@refs/(heads/main|tags/v.+)$' \ + | jq -r .payload | base64 -d | jq '.predicate.scanner.result | {scanned_at, counts, fixable}' +``` + +The same results for every image are on the [security status](/docs/reference/security-status) page, which also explains how to read them. + +## Scan it yourself +You don't have to trust our scan. The images are public and work with any scanner. For example, with [Grype](https://github.com/anchore/grype): + +```bash [Terminal] +grype serversideup/php:8.5-cli +``` + +Add `--only-fixed` to list only the findings that have a fix available. Those are the ones a rebuild or an upgrade can change. See [fixable and unfixed findings](/docs/reference/security-status#most-findings-have-no-fix-yet) for why the full list is usually much longer. diff --git a/docs/content/docs/8.reference/3.security-status.md b/docs/content/docs/8.reference/3.security-status.md new file mode 100644 index 00000000..74b5ba5f --- /dev/null +++ b/docs/content/docs/8.reference/3.security-status.md @@ -0,0 +1,45 @@ +--- +head.title: 'Security Status - Docker PHP - Server Side Up' +description: 'Known vulnerabilities in every published serversideup/php image, from the scan that runs after each production build.' +layout: docs +title: Security status +--- + +::lead-p +This page shows the known vulnerabilities in every image we publish. It updates after each scan. The numbers to watch are the fixable ones. +:: + +## When and how images are scanned +A scan runs after every production publish. That covers each release, the Tuesday 08:00 UTC weekly rebuild, and the runs that ship new PHP patches. [Grype](https://github.com/anchore/grype) scans the `linux/amd64` image of every published tag, pulled from GitHub Packages. + +The scan job has read-only access and no registry credentials. Grype runs in its own container. A separate job that never runs scanner code signs the results and publishes them. + +Each image's results are also attached to it as a signed attestation. [Verifying images](/docs/guide/verifying-images#verify-the-vulnerability-scan) shows how to check it. + +## Most findings have no fix yet +A scanner matches every package in the image against public CVE databases. Many of the CVEs it reports are ones the distribution has marked "not fixed" or "won't fix". For those, there is no newer package to install. A typical Debian image shows a large number of these. A scan of `8.5-cli-bookworm` found 18 critical and 144 high CVEs. None of them had a fix. + +No rebuild can change an unfixed finding. The fixable count is the meaningful number: findings where a newer package exists. The weekly rebuild picks those up on its own. The docker-library project explains this in more depth in [Why does my security scanner show that an image has CVEs?](https://github.com/docker-library/faq#why-does-my-security-scanner-show-that-an-image-has-cves) + +## Fixable findings that survive a rebuild become issues +A critical or high CVE that has a fix and shows up in two scans in a row survived a rebuild. Something is holding it back. Each one gets a public GitHub issue labeled [`security-scan`](https://github.com/serversideup/docker-php/issues?q=label%3Asecurity-scan), assigned to the maintainer. + +- An issue updates in place on every scan and closes itself once a scan no longer finds the CVE. +- A scan opens at most 10 new issues. The rest wait for the next scan. + +These issues track CVEs that are already public upstream. To report a problem in our own scripts, configs, or workflows, use [private vulnerability reporting](https://github.com/serversideup/docker-php/security/policy) instead. + +## Current results + +::security-status +:: + +## Read the results from your own tools +The data behind this table is public JSON on the `security-reports` branch: + +```text [URL] +https://raw.githubusercontent.com/serversideup/docker-php/security-reports/reports/index.json +``` + +- `reports/index.json` lists every image with its counts per severity and how many of each are fixable. Its `fixable` list holds each fixable critical or high finding, with the images it affects and `consecutive_scans`, the number of scans in a row that found it. +- `reports/images/.json`, such as `reports/images/8.5.12-cli-bookworm.json`, holds one image's critical, high, and medium findings. Low and negligible findings are only counted. diff --git a/renovate.json b/renovate.json index 01a33d7e..d1cb568f 100644 --- a/renovate.json +++ b/renovate.json @@ -1,14 +1,14 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended"], + "extends": ["config:recommended", "helpers:pinGitHubActionDigests"], "enabledManagers": ["custom.regex", "github-actions", "npm"], "customManagers": [ { "customType": "regex", "description": "Versions with a '# renovate:' comment on the line above", - "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml"], + "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml", ".github/workflows/service_scan-images.yml"], "matchStrings": [ - "#\\s*renovate:\\s*datasource=(?\\S+)\\s+depName=(?\\S+)(?:\\s+versioning=(?\\S+))?\\s*\\n\\s*[A-Z0-9_]+:\\s*\"?(?[^\"\\s]+)\"?" + "#\\s*renovate:\\s*datasource=(?\\S+)\\s+depName=(?\\S+)(?:\\s+versioning=(?\\S+))?\\s*\\n\\s*[A-Z0-9_]+:\\s*\"?(?[^\"\\s@]+)(?:@(?sha256:[a-f0-9]{64}))?\"?" ] }, { @@ -53,6 +53,16 @@ } }, "packageRules": [ + { + "description": "Wait three days before proposing a new release, so a hijacked or broken one is usually caught first. git-refs and the nginx.org listings have no release dates, so they are not covered.", + "matchDatasources": ["github-releases", "github-tags", "docker"], + "minimumReleaseAge": "3 days" + }, + { + "description": "zizmor's releases are tagged v1.2.3 but its container image is tagged 1.2.3", + "matchDepNames": ["zizmorcore/zizmor"], + "extractVersion": "^v(?.+)$" + }, { "description": "Bump NGINX on every OS in one pull request", "matchDatasources": ["custom.nginx-alpine", "custom.nginx-debian"], diff --git a/scripts/scan-summary.sh b/scripts/scan-summary.sh new file mode 100755 index 00000000..76826466 --- /dev/null +++ b/scripts/scan-summary.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: scan-summary.sh +# +# Reduces Grype's JSON for one image to the summary the security reports publish (see +# service_scan-images.yml): counts per severity, how many of those have a fix, and the +# critical, high, and medium findings. Low and negligible findings are only counted, +# because Debian lists hundreds of them that it does not plan to fix. + +grype_json="${1:?Usage: scan-summary.sh }" + +jq --arg image "$2" --arg variation "$3" --arg php "$4" --arg os "$5" --arg digest "$6" ' + def severities: ["Critical", "High", "Medium", "Low", "Negligible", "Unknown"]; + def count_by_severity: (reduce .[] as $s ((severities | map({(.): 0}) | add); .[$s] += 1)); + [.matches[] | { + id: .vulnerability.id, + severity: .vulnerability.severity, + package: .artifact.name, + version: .artifact.version, + type: .artifact.type, + fixed_in: (.vulnerability.fix.versions // []), + state: .vulnerability.fix.state, + url: .vulnerability.dataSource + }] as $found + | { + image: $image, + variation: $variation, + php: $php, + os: $os, + digest: $digest, + scanned_at: (.descriptor.timestamp // (now | todate)), + scanner: {name: "grype", version: .descriptor.version, db_built: .descriptor.db.status.built}, + counts: ($found | map(.severity) | count_by_severity), + fixable: ($found | map(select(.state == "fixed") | .severity) | count_by_severity), + vulnerabilities: ($found + | map(select(.severity == "Critical" or .severity == "High" or .severity == "Medium")) + | unique_by([.id, .package, .version]) + | sort_by((.severity as $s | ["Critical", "High", "Medium"] | index($s)), .id)) + }' "$grype_json" diff --git a/scripts/security-report.sh b/scripts/security-report.sh new file mode 100755 index 00000000..b1733b3f --- /dev/null +++ b/scripts/security-report.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: security-report.sh +# +# Turns the per-image summaries from scan-summary.sh into the public security reports +# on the security-reports branch: reports/images/.json for each image and +# reports/index.json for all of them, which the Security status docs page reads. +# +# The index lists every critical or high finding that has a fix, with the number of +# scans in a row that found it. The weekly rebuild clears most of these on its own, +# so service_scan-images.yml only opens an issue once a finding survives a rebuild +# (two scans in a row). The previous index in is how a scan knows what +# the last one found. The fixable list is printed so the workflow can manage issues. + +scans_dir="${1:?Usage: security-report.sh }" +reports_dir="${2:?Usage: security-report.sh }" +run_url="${3:?Usage: security-report.sh }" + +previous='{"fixable": []}' +if [ -f "$reports_dir/index.json" ]; then + previous=$(cat "$reports_dir/index.json") +fi + +scans=$(find "$scans_dir" -name '*.json' -print0 | xargs -0 -r jq -s 'sort_by(.image)') +scans="${scans:-[]}" +if [ "$(echo "$scans" | jq 'length')" = "0" ]; then + echo "No scan summaries found in $scans_dir" >&2 + exit 1 +fi + +# Images that are no longer published (an end-of-life OS, a superseded patch) drop out +# of the reports, so the page only ever describes images people can pull today. +rm -rf "$reports_dir/images" +mkdir -p "$reports_dir/images" +echo "$scans" | jq -c '.[]' | while read -r scan; do + echo "$scan" | jq '.' > "$reports_dir/images/$(echo "$scan" | jq -r .image).json" +done + +echo "$scans" | jq --argjson previous "$previous" --arg run_url "$run_url" ' + ($previous.fixable | map({(.id): .consecutive_scans}) | add // {}) as $seen + | { + generated_at: (now | todate), + run_url: $run_url, + scanner: .[0].scanner, + images: map({image, variation, php, os, digest, scanned_at, counts, fixable}), + fixable: ( + [.[] as $scan | $scan.vulnerabilities[] + | select((.severity == "Critical" or .severity == "High") and .state == "fixed") + | {id, severity, image: $scan.image, package, version, fixed_in}] + | group_by(.id) + | map({ + id: .[0].id, + severity: .[0].severity, + packages: (map(.package) | unique), + images: map({image, package, version, fixed_in}), + consecutive_scans: (($seen[.[0].id] // 0) + 1) + }) + | sort_by((.severity as $s | ["Critical", "High"] | index($s)), .id)) + }' > "$reports_dir/index.json" + +jq '.fixable' "$reports_dir/index.json" diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh index 0bbb88f2..3479218b 100755 --- a/scripts/tests/run.sh +++ b/scripts/tests/run.sh @@ -93,6 +93,55 @@ assert_contains "$matrix_json" '"php_variation":"fpm-nginx","php_extension_overr assert_contains "$matrix_json" '"php_base_tag":"php-base-8.1.34-cli-bookworm","docker_library_ref":"frozen-sha"' "uses a minor version's own docker-library ref" assert_contains "$matrix_json" '"php_variation":"fpm-nginx","php_extension_overrides":"","php_base":"cli","php_base_tag":"php-base-8.6-rc-cli-trixie"' "lets a minor version swap a variation's docker-library base" +# Grype output trimmed to the fields the summary reads: a fixable critical (reported +# twice, as Grype does for one package matched two ways), a high Debian won't fix, and +# a negligible one. +grype_match() { + printf '{"vulnerability":{"id":"%s","severity":"%s","dataSource":"https://example.test/%s","fix":{"versions":%s,"state":"%s"}},"artifact":{"name":"%s","version":"1.0","type":"deb"}}' "$@" +} +grype_scan() { + echo "{\"descriptor\":{\"version\":\"0.121.0\",\"timestamp\":\"2026-10-30T08:00:00Z\",\"db\":{\"status\":{\"built\":\"2026-10-30T06:00:00Z\"}}},\"matches\":[$1]}" +} + +echo +echo "scan-summary.sh" +grype_scan "$(grype_match CVE-1 Critical CVE-1 '["1.1"]' fixed libssl3),$(grype_match CVE-1 Critical CVE-1 '["1.1"]' fixed libssl3),$(grype_match CVE-2 High CVE-2 '[]' wont-fix libsqlite3),$(grype_match CVE-3 Negligible CVE-3 '[]' not-fixed libc6)" > "$image_details/grype.json" +scan=$(bash "$scripts_dir/scan-summary.sh" "$image_details/grype.json" 8.5.12-cli-bookworm cli 8.5.12 bookworm sha256:aaa | jq -c .) +assert_contains "$scan" '"counts":{"Critical":2,"High":1,"Medium":0,"Low":0,"Negligible":1,"Unknown":0}' "counts every match per severity" +assert_contains "$scan" '"fixable":{"Critical":2,"High":0,' "counts the matches that have a fix" +assert_contains "$scan" '"scanner":{"name":"grype","version":"0.121.0","db_built":"2026-10-30T06:00:00Z"}' "records the scanner and database it used" +assert_contains "$(echo "$scan" | jq -c '[.vulnerabilities[].id]')" '["CVE-1","CVE-2"]' "lists one finding per package and leaves negligible ones out" + +echo +echo "security-report.sh" +scans="$image_details/scans" +reports="$image_details/reports" +mkdir -p "$scans" +echo "$scan" > "$scans/8.5.12-cli-bookworm.json" +bash "$scripts_dir/scan-summary.sh" "$image_details/grype.json" 8.4.27-cli-bookworm cli 8.4.27 bookworm sha256:bbb > "$scans/8.4.27-cli-bookworm.json" +first=$(bash "$scripts_dir/security-report.sh" "$scans" "$reports" https://example.test/run/1 | jq -c .) +assert_contains "$first" '"id":"CVE-1","severity":"Critical","packages":["libssl3"]' "lists fixable critical findings" +assert_contains "$first" '"consecutive_scans":1' "counts a new finding as seen once" +assert_contains "$(echo "$first" | jq -c '[.[].id]')" '["CVE-1"]' "leaves findings without a fix out of the fixable list" +assert_contains "$(jq -c '[.images[].image]' "$reports/index.json")" '["8.4.27-cli-bookworm","8.5.12-cli-bookworm"]' "indexes every scanned image" +assert_contains "$(ls "$reports/images")" "8.5.12-cli-bookworm.json" "writes a report per image" + +second=$(bash "$scripts_dir/security-report.sh" "$scans" "$reports" https://example.test/run/2 | jq -c .) +assert_contains "$second" '"consecutive_scans":2' "counts a finding that survived a rebuild" + +rm "$scans/8.4.27-cli-bookworm.json" +grype_scan "$(grype_match CVE-2 High CVE-2 '[]' wont-fix libsqlite3)" > "$image_details/grype.json" +bash "$scripts_dir/scan-summary.sh" "$image_details/grype.json" 8.5.12-cli-bookworm cli 8.5.12 bookworm sha256:aaa > "$scans/8.5.12-cli-bookworm.json" +third=$(bash "$scripts_dir/security-report.sh" "$scans" "$reports" https://example.test/run/3 | jq -c .) +assert_contains "$third" '[]' "drops a finding the rebuild fixed" +assert_contains "$(ls "$reports/images")" "8.5.12-cli-bookworm.json" "keeps reports for images still published" +if [ -e "$reports/images/8.4.27-cli-bookworm.json" ]; then + echo "โŒ removes reports for images no longer published" + failures=$((failures + 1)) +else + echo "โœ… removes reports for images no longer published" +fi + echo if [ "$failures" -gt 0 ]; then echo "$failures check(s) failed" >&2 From 6e6ea1634d78726ac8fea845d0d77c419cf0855f Mon Sep 17 00:00:00 2001 From: Jay Rogers Date: Fri, 30 Oct 2026 21:05:43 +0000 Subject: [PATCH 5/5] Simplifed scanning --- .../action_publish-images-production.yml | 11 +- .github/workflows/service_scan-images.yml | 280 +++++------------- .oss-scanner/threat_model.md | 9 +- AGENTS.md | 2 +- SECURITY.md | 4 +- docs/app/components/SecurityStatus.vue | 177 ----------- .../docs/5.guide/7.verifying-images.md | 24 +- .../docs/8.reference/3.security-status.md | 45 --- .../8.reference/3.vulnerability-scanning.md | 34 +++ scripts/scan-summary.sh | 40 --- scripts/security-report.sh | 62 ---- scripts/tests/run.sh | 49 --- 12 files changed, 125 insertions(+), 612 deletions(-) delete mode 100644 docs/app/components/SecurityStatus.vue delete mode 100644 docs/content/docs/8.reference/3.security-status.md create mode 100644 docs/content/docs/8.reference/3.vulnerability-scanning.md delete mode 100755 scripts/scan-summary.sh delete mode 100755 scripts/security-report.sh diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml index 20e67116..0c1f81c0 100644 --- a/.github/workflows/action_publish-images-production.yml +++ b/.github/workflows/action_publish-images-production.yml @@ -115,20 +115,15 @@ jobs: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - # Scans what was just published and updates the public security reports. + # Scans what was just published: counts in the run summary, fixable findings as code scanning alerts. scan: needs: [release, publish] if: needs.publish.result == 'success' uses: ./.github/workflows/service_scan-images.yml permissions: actions: read - contents: write - issues: write - packages: write - id-token: write - secrets: - DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + contents: read + security-events: write report: needs: [release, setup, build, test, publish] diff --git a/.github/workflows/service_scan-images.yml b/.github/workflows/service_scan-images.yml index 273f2e70..89203bbf 100644 --- a/.github/workflows/service_scan-images.yml +++ b/.github/workflows/service_scan-images.yml @@ -1,16 +1,13 @@ name: Scan Images -# Runs after every production publish, or by hand for the latest one. Each published -# image is scanned for known vulnerabilities, and the results are published three ways: -# a signed vulnerability attestation on each image, the reports on the security-reports -# branch that the Security status docs page reads, and an issue for each fixable -# critical or high finding that a rebuild did not clear. +# Runs after every production publish, or by hand for the latest one. Grype scans each +# published image. Every finding is counted in the run summary, which anyone can read, +# and the findings that have a fix become code scanning alerts in the Security tab. # -# A compromised scanner has nothing to steal and nothing it can publish. The scan job's -# token can only read, it holds no registry credentials, and Grype runs in its own -# container without the runner's environment. Images are pulled anonymously from GHCR, -# which has no pull limit for public images. The report job does the signing and -# writing and never runs scanner code: it only reads the summaries the scan jobs upload. +# A compromised scanner has nothing to steal: the scan job has no token, no registry +# credentials, and Grype runs in its own container without the runner's environment. +# Images are pulled anonymously from GHCR, which has no pull limit for public images. +# Only the alerts job can write, and it never runs Grype. on: workflow_call: inputs: @@ -18,11 +15,6 @@ on: type: string default: '' description: 'The production run whose published images to scan. Defaults to the calling run.' - secrets: - DOCKER_HUB_USERNAME: - required: true - DOCKER_HUB_ACCESS_TOKEN: - required: true workflow_dispatch: inputs: run-id: @@ -44,9 +36,11 @@ jobs: timeout-minutes: 5 permissions: actions: read + contents: read outputs: matrix: ${{ steps.list.outputs.matrix }} count: ${{ steps.list.outputs.count }} + main-sha: ${{ steps.run.outputs.main-sha }} steps: - name: Find the production run to scan id: run @@ -62,6 +56,9 @@ jobs: RUN_ID=$(gh run list --workflow action_publish-images-production.yml --status success --limit 1 --json databaseId --jq '.[0].databaseId') fi echo "id=${RUN_ID:-$GITHUB_RUN_ID}" | tee -a "$GITHUB_OUTPUT" + # Alerts describe the published images, not a commit, so every scan reports + # against main. Release and weekly scans then share one list of alerts. + echo "main-sha=$(gh api "repos/${GH_REPO}/commits/main" --jq .sha)" | tee -a "$GITHUB_OUTPUT" - name: Download the published image records uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -77,7 +74,7 @@ jobs: run: | # shellcheck disable=SC2016 # $repository is a jq variable MATRIX=$(find image-details -name '*.published.json' -print0 | xargs -0 -r jq -s -c --arg repository "$SCAN_REPOSITORY" ' - map({variation, image: "\(.php)-\(.variation)-\(.os)", php, os, tag: "\($repository):\(.php)-\(.variation)-\(.os)"}) + map({image: "\(.php)-\(.variation)-\(.os)", variation, php, os, tag: "\($repository):\(.php)-\(.variation)-\(.os)"}) | group_by(.variation) | { include: map({ variation: .[0].variation, images: tojson }) }') MATRIX="${MATRIX:-{\"include\":[]\}}" @@ -92,46 +89,48 @@ jobs: name: scan ${{ matrix.variation }} runs-on: ubuntu-24.04 timeout-minutes: 60 - permissions: - contents: read + permissions: {} strategy: fail-fast: false matrix: ${{ fromJson(needs.plan.outputs.matrix) }} steps: - - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - # The database goes in a volume so every image in the group is scanned against the # same one, downloaded once. - name: Download the vulnerability database run: docker run --rm -v grype-db:/db -e GRYPE_DB_CACHE_DIR=/db "anchore/grype:${GRYPE_VERSION}" db update - # The index digest is what users pull, so it is what the summary and the attestation name. + # Grype names every image's findings after the same file (php//lib/apk/db/installed), + # which code scanning would merge into one alert across all images. Each finding is + # moved under its image, keyed by PHP minor version so an alert stays the same alert + # when a new PHP patch ships. - name: Scan every image in the group env: IMAGES: ${{ matrix.images }} VARIATION: ${{ matrix.variation }} SCAN_ATTEMPTS: 3 run: | - mkdir -p scans raw + mkdir -p raw fixable + chmod 777 raw scan_image() { - local image="$1" php="$2" os="$3" tag="$4" digest + local image="$1" tag="$2" digest digest=$(docker buildx imagetools inspect "$tag" --format '{{json .Manifest.Digest}}' | jq -r .) || return 1 - docker run --rm -v grype-db:/db -e GRYPE_DB_CACHE_DIR=/db -e GRYPE_DB_AUTO_UPDATE=false \ - "anchore/grype:${GRYPE_VERSION}" "registry:${SCAN_REPOSITORY}@${digest}" \ - --platform "$SCAN_PLATFORM" --output json --quiet > "raw/${image}.json" || return 1 - bash scripts/scan-summary.sh "raw/${image}.json" "$image" "$VARIATION" "$php" "$os" "$digest" > "scans/${image}.json" + docker run --rm -v grype-db:/db -v "$PWD/raw:/out" -e GRYPE_DB_CACHE_DIR=/db -e GRYPE_DB_AUTO_UPDATE=false \ + "anchore/grype:${GRYPE_VERSION}" "registry:${SCAN_REPOSITORY}@${digest}" --platform "$SCAN_PLATFORM" --quiet \ + --output "json=/out/${image}.json" --output "sarif=/out/${image}.sarif" } FAILED=() + { + echo "## ${VARIATION}: vulnerabilities by image" + echo "" + echo "| Image | Critical | High | Medium | Fixable |" + echo "|---|---|---|---|---|" + } >> "$GITHUB_STEP_SUMMARY" while IFS=$'\t' read -r IMAGE PHP OS TAG; do echo "::group::${IMAGE}" attempt=1 - until scan_image "$IMAGE" "$PHP" "$OS" "$TAG"; do - rm -f "scans/${IMAGE}.json" + until scan_image "$IMAGE" "$TAG"; do if [ "$attempt" -ge "$SCAN_ATTEMPTS" ]; then echo "::error title=Scan failed::${IMAGE} could not be scanned after ${SCAN_ATTEMPTS} attempts" FAILED+=("$IMAGE") @@ -140,193 +139,74 @@ jobs: sleep $(( 10 * attempt )) attempt=$(( attempt + 1 )) done - if [ -f "scans/${IMAGE}.json" ]; then - jq -r '"Critical \(.counts.Critical) (\(.fixable.Critical) fixable), High \(.counts.High) (\(.fixable.High) fixable)"' "scans/${IMAGE}.json" + if [ -s "raw/${IMAGE}.json" ] && [ -s "raw/${IMAGE}.sarif" ]; then + case "$PHP" in + *-rc*) KEY="${PHP}-${OS}" ;; + *) KEY="${PHP%.*}-${OS}" ;; + esac + # shellcheck disable=SC2016 # the backticks are Markdown + jq -r --arg image "$IMAGE" '[.matches[].vulnerability] as $v + | def count(s): [$v[] | select(.severity == s)] | length; + "| `\($image)` | \(count("Critical")) | \(count("High")) | \(count("Medium")) | \([$v[] | select(.fix.state == "fixed")] | length) |"' \ + "raw/${IMAGE}.json" | tee -a "$GITHUB_STEP_SUMMARY" + jq --slurpfile scan "raw/${IMAGE}.json" --arg key "$KEY" ' + ([$scan[0].matches[] | select(.vulnerability.fix.state == "fixed") | "\(.vulnerability.id)-\(.artifact.name)"] | unique) as $fixable + | .runs[0] + | { + rules: [.tool.driver.rules[] | select(.id as $id | $fixable | index($id))], + results: [.results[] | select(.ruleId as $id | $fixable | index($id)) + | .locations |= map(.physicalLocation.artifactLocation.uri |= ($key + "/" + sub("^[^/]*//"; ""))) + | .partialFingerprints = {primaryLocationLineHash: "\($key):\(.ruleId):1"}] + }' "raw/${IMAGE}.sarif" > "fixable/${IMAGE}.json" fi echo "::endgroup::" done < <(echo "$IMAGES" | jq -r '.[] | [.image, .php, .os, .tag] | @tsv') - { - echo "## ${VARIATION}: vulnerabilities by image" - echo "" - echo "| Image | Critical | High | Fixable critical or high |" - echo "|---|---|---|---|" - # shellcheck disable=SC2016 # the backticks are Markdown - find scans -name '*.json' -print0 | xargs -0 -r jq -s -r 'sort_by(.image)[] - | "| `\(.image)` | \(.counts.Critical) | \(.counts.High) | \(.fixable.Critical + .fixable.High) |"' - } >> "$GITHUB_STEP_SUMMARY" - if [ "${#FAILED[@]}" -gt 0 ]; then echo "::error title=Scan failed::${FAILED[*]} were not scanned" exit 1 fi - - name: Upload the scan summaries + # One run for the whole variation keeps the upload within code scanning's limit of 20 runs per file. + TEMPLATE=$(find raw -name '*.sarif' | head -n1) + jq -s --slurpfile template "$TEMPLATE" '. as $images | { + "$schema": $template[0]["$schema"], + version: $template[0].version, + runs: [{ + tool: {driver: ($template[0].runs[0].tool.driver | .rules = ([$images[].rules[]] | unique_by(.id)))}, + results: [$images[].results[]] + }] + }' fixable/*.json > "${VARIATION}.sarif" + echo "$(jq '.runs[0].results | length' "${VARIATION}.sarif") fixable findings across $(find fixable -name '*.json' | wc -l) images" + + - name: Upload the findings uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: scans-${{ matrix.variation }} - path: scans/ + name: sarif-${{ matrix.variation }} + path: ${{ matrix.variation }}.sarif retention-days: 7 - report: + alerts: needs: [plan, scan] if: needs.scan.result == 'success' + name: alerts ${{ matrix.variation }} runs-on: ubuntu-24.04 - timeout-minutes: 60 - # Two runs writing the reports branch at once would race on the previous index. - concurrency: - group: security-reports + timeout-minutes: 10 permissions: - contents: write - issues: write - packages: write - id-token: write - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - REPORTS_BRANCH: security-reports - STATUS_PAGE_URL: https://serversideup.net/open-source/docker-php/docs/reference/security-status - ASSIGNEE: jaydrogers - ISSUE_LABEL: security-scan - # The first scan after this lands could find a backlog. The rest wait for the next scan. - MAX_NEW_ISSUES: 10 + security-events: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} steps: - - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download the scan summaries + - name: Download the findings uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - pattern: scans-* - path: scans - merge-multiple: true - - # Only this job writes the branch. It holds nothing but reports, so it starts as an - # orphan the first time. - - name: Check out the reports branch - run: | - git init --quiet "$REPORTS_BRANCH" - cd "$REPORTS_BRANCH" - git remote add origin "${GITHUB_SERVER_URL}/${GH_REPO}.git" - if git fetch --quiet --depth 1 origin "$REPORTS_BRANCH" 2> /dev/null; then - git checkout --quiet -b "$REPORTS_BRANCH" FETCH_HEAD - else - echo "No ${REPORTS_BRANCH} branch yet, starting it" - git checkout --quiet --orphan "$REPORTS_BRANCH" - fi - - - name: Build the reports - id: reports - run: bash scripts/security-report.sh scans "${REPORTS_BRANCH}/reports" "$RUN_URL" > fixable.json - - - name: Publish the reports - run: | - cd "$REPORTS_BRANCH" - git add --all reports - if git diff --cached --quiet; then - echo "The reports did not change" - exit 0 - fi - git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - commit --quiet -m "Security scan $(date -u +%Y-%m-%d)" -m "$RUN_URL" - AUTH=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0) - git -c "http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION: basic ${AUTH}" push --quiet origin "HEAD:${REPORTS_BRANCH}" - - - name: Login to DockerHub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + name: sarif-${{ matrix.variation }} - - name: Login to GitHub Container Registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + - name: Update the code scanning alerts + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Install cosign - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - - # Keyless, like the image signatures: the attestation is tied to this workflow - # through its OIDC token. Both registries hold the same index, so both get one. - - name: Attest the scan results to each image - env: - ATTEST_ATTEMPTS: 3 - REPOSITORIES: docker.io/serversideup/php ghcr.io/serversideup/php - WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/.github/workflows/service_scan-images.yml - run: | - FAILED=() - for SCAN in scans/*.json; do - IMAGE=$(jq -r .image "$SCAN") - DIGEST=$(jq -r .digest "$SCAN") - jq --arg run_url "$RUN_URL" --arg run_id "$GITHUB_RUN_ID" --arg builder "$WORKFLOW_URL" '{ - invocation: {uri: $run_url, event_id: $run_id, "builder.id": $builder}, - scanner: {uri: "pkg:github/anchore/grype@v\(.scanner.version)", version: .scanner.version, db: {uri: "https://grype.anchore.io", version: .scanner.db_built}, result: .}, - metadata: {scanStartedOn: .scanned_at, scanFinishedOn: .scanned_at} - }' "$SCAN" > predicate.json - for REPOSITORY in $REPOSITORIES; do - attempt=1 - until cosign attest --yes --type vuln --predicate predicate.json "${REPOSITORY}@${DIGEST}"; do - if [ "$attempt" -ge "$ATTEST_ATTEMPTS" ]; then - echo "::error title=Attestation failed::${REPOSITORY}@${DIGEST} (${IMAGE})" - FAILED+=("$IMAGE") - break - fi - sleep $(( 15 * attempt )) - attempt=$(( attempt + 1 )) - done - done - done - if [ "${#FAILED[@]}" -gt 0 ]; then - echo "::error title=Attestation failed::${FAILED[*]} are missing a scan attestation" - exit 1 - fi - - # Every finding here is an already disclosed upstream CVE that anyone can see by - # scanning the image, so the issues are public. Problems in our own code go through - # private advisories instead (SECURITY.md). - - name: Open and close issues for fixable findings - if: always() && steps.reports.outcome == 'success' - run: | - gh label create "$ISSUE_LABEL" --description "A fixable vulnerability survived a rebuild" --color D93F0B --force - OPEN=$(gh issue list --label "$ISSUE_LABEL" --state open --limit 500 --json number,title) - NEW=0 - - while read -r FINDING; do - ID=$(echo "$FINDING" | jq -r .id) - SEVERITY=$(echo "$FINDING" | jq -r .severity) - PACKAGES=$(echo "$FINDING" | jq -r '.packages | join(", ")') - TITLE="${ID}: ${SEVERITY} in ${PACKAGES} has a fix" - { - echo "**${ID}** (${SEVERITY}) has a fix available, and it was still in the published images after a rebuild. It has been found in $(echo "$FINDING" | jq .consecutive_scans) scans in a row." - echo "" - echo "The weekly rebuild picks up most fixes on its own, so something is holding this one back: a pinned version, an upstream image that has not been rebuilt yet, or a frozen base such as PHP 8.1's." - echo "" - echo "| Image | Package | Installed | Fixed in |" - echo "|---|---|---|---|" - echo "$FINDING" | jq -r '.images[] | "| `\(.image)` | \(.package) | \(.version) | \(.fixed_in | join(", ")) |"' - echo "" - echo "[Security status]($STATUS_PAGE_URL) ยท [Scan run]($RUN_URL). This issue updates on every scan and closes itself once a scan no longer finds it." - } > issue.md - - EXISTING=$(echo "$OPEN" | jq -r --arg id "$ID" '[.[] | select(.title | startswith($id + ":"))][0].number // ""') - if [ -n "$EXISTING" ]; then - gh issue edit "$EXISTING" --title "$TITLE" --body-file issue.md - elif [ "$NEW" -lt "$MAX_NEW_ISSUES" ]; then - gh issue create --title "$TITLE" --body-file issue.md --label "$ISSUE_LABEL" --assignee "$ASSIGNEE" - NEW=$(( NEW + 1 )) - else - echo "::warning title=Issue deferred::${ID} waits for the next scan, ${MAX_NEW_ISSUES} issues were already opened in this one." - fi - done < <(jq -c '.[] | select(.consecutive_scans >= 2)' fixable.json) - - # Close the issues whose finding is gone, by fix or because the image is no longer published. - echo "$OPEN" | jq -r '.[] | [.number, (.title | split(":")[0])] | @tsv' | while IFS=$'\t' read -r NUMBER ID; do - if ! jq -e --arg id "$ID" 'any(.[]; .id == $id)' fixable.json > /dev/null; then - gh issue close "$NUMBER" --comment "No longer found in the [latest scan]($RUN_URL)." - fi - done + sarif_file: ${{ matrix.variation }}.sarif + category: grype/${{ matrix.variation }} + ref: refs/heads/main + sha: ${{ needs.plan.outputs.main-sha }} diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index f86f1e51..859b1740 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -8,7 +8,7 @@ Untrusted input: - **Pull requests from forks** against `.github/workflows/`. A fork can control the PR title, branch name, and file contents. It must never reach repository secrets, the Depot OIDC trust, or any publish to Docker Hub or GHCR. - **Workflows that start without a pull request.** None of these run fork code: - `scheduled-task_watch-php-releases.yml` runs hourly on `schedule` and on `workflow_dispatch`. It reads php.net's release feed and Docker Hub's tag API, and when a patch has no image it starts the production workflow with `actions: write`. The patch version from php.net reaches the run title through `gh workflow run -f reason=...`. - - `service_scan-images.yml` runs on `workflow_call` after every production publish and on `workflow_dispatch`. Grype, its vulnerability database, and the images it scans are third-party input. The `scan` job has a read-only token, no registry credentials, and runs Grype in its own container. The `report` job holds `contents: write`, `issues: write`, `packages: write`, and `id-token: write`, never runs scanner code, and only reads the JSON summaries the scan jobs upload. Text from those summaries (CVE IDs, package names, versions) ends up in public issue titles and bodies. + - `service_scan-images.yml` runs on `workflow_call` after every production publish and on `workflow_dispatch`. Grype, its vulnerability database, and the images it scans are third-party input. The `scan` job has no token and no registry credentials, and runs Grype in its own container. The `alerts` job holds only `security-events: write`, never runs Grype, and uploads the SARIF the scan jobs produce. Text from that SARIF (CVE IDs, package names, versions) ends up in code scanning alerts. - `scheduled-task_scorecard.yml` runs on `push` to `main`, `schedule`, `branch_protection_rule`, and `workflow_dispatch`. - **Downloads during image builds** (the PHP tarball from php.net, the docker-library/php Dockerfiles, phpredis and liblzf source, s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures. The PHP tarball is checked against php.net's SHA256 and its GPG signature, docker-library/php and liblzf are pinned to commits, and the other downloads are pinned to release tags in `scripts/conf/php-versions-base-config.yml`. @@ -27,8 +27,7 @@ Most: - Web server configs in `src/variations/*/etc/` and PHP-FPM pools in `src/php-fpm.d/` - The unprivileged model. A path from `www-data` to root, or a default that runs something as root, matters. - `.github/workflows/` and `scripts/` used by CI (what gets built, tested, and published) -- The signing identities. cosign signs images keylessly from `.github/workflows/service_publish-images.yml` and scan attestations from `.github/workflows/service_scan-images.yml`, at `refs/heads/main` or a `v*` tag, with GitHub's OIDC issuer. Users trust anything signed by those identities, so a way to run other code inside those workflows matters. -- The `security-reports` branch. Only the `report` job in `service_scan-images.yml` writes it, and the docs site's security status page reads `reports/index.json` from it. +- The signing identities. cosign signs images keylessly from `.github/workflows/service_publish-images.yml`, at `refs/heads/main` or a `v*` tag, with GitHub's OIDC issuer. Users trust anything signed by that identity, so a way to run other code inside that workflow matters. Least, or out of scope: - The `docs/` site @@ -54,7 +53,7 @@ kill %1 # stops it, like docke ## How you rate severity - **Critical:** remote, unauthenticated code execution or arbitrary file read (for example, `.env` or application source) through a config we ship with default settings. Also a fork pull request that can publish an image, sign as one of our workflows, or read a secret. -- **High:** escalation from `www-data` to root. A default that exposes secrets, the PHP-FPM status page, or FastCGI to the network. A fork pull request that can run code with write access to the repository or with Depot or registry credentials. Scanner output that can write to `security-reports`, sign an attestation, or run code in the `report` job. A downloaded artifact that is used without verification. +- **High:** escalation from `www-data` to root. A default that exposes secrets, the PHP-FPM status page, or FastCGI to the network. A fork pull request that can run code with write access to the repository or with Depot or registry credentials. Scanner output that can run code in the `alerts` job or write anything beyond code scanning alerts. A downloaded artifact that is used without verification. - **Medium:** client IP spoofing past `TRUSTED_PROXY`, so that IP-based controls are bypassed. Plain HTTP served when the docs say SSL is enforced. Denial of service from a single unauthenticated request. - **Low:** version banners and other information leaks that give no access. Hardening gaps with no demonstrated impact. @@ -65,4 +64,4 @@ When a finding only applies with a non-default environment variable, rate it one - `PHP_DISPLAY_ERRORS` and similar settings that operators can turn on for development are not findings. - Images run as `www-data` and can be switched to root with `USER root`. That design is documented and intentional. - Packages that are not pinned, and floating base image tags, are intentional. Weekly rebuilds pick up security fixes, and the release watcher picks up new PHP patches within the hour. -- Public `security-scan` issues that list CVEs in our images are intentional. They are already disclosed upstream CVEs that anyone can find by scanning a public image. +- Public scan results that list CVEs in our images are intentional. They are already disclosed upstream CVEs that anyone can find by scanning a public image. diff --git a/AGENTS.md b/AGENTS.md index 9fa7360c..0095cd5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-php-base.yml` compiles each PHP base (one job per PHP version, base OS, and `php_base`) and saves it to the Depot Registry as `-php-base---`, always with `no-cache` so OS security fixes (a patched libbrotli or OpenSSL) land on the next run instead of when Debian or Alpine publish a new base image; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`. - A broken image can never reach a public tag: what users pull is byte-for-byte what the tests ran. Publishing is all or nothing: if any image fails to build or fails its test, no image from that run is published, including on the weekly rebuilds. Saved images live in the Depot Registry until the project's retention setting removes them. - Build jobs that save images attach an SPDX SBOM and SLSA provenance (`mode=max`) to the image index. After the digest check, `service_publish-images.yml` signs each registry's copy of the index (one signature per registry, covering every tag) with cosign keyless signing through the workflow's OIDC token, so there is no key to store. Users verify against the identity `.github/workflows/service_publish-images.yml@refs/heads/main` or `@refs/tags/v*`; the commands are in `docs/content/docs/5.guide/7.verifying-images.md`. Images published before this have provenance only. -- `service_scan-images.yml` runs after every production publish (and by hand for the latest one). Grype scans the `linux/amd64` image of every published tag, pulled anonymously from GHCR. The scan job has a read-only token and no registry credentials, and Grype runs in its own container, so a compromised scanner has nothing to steal or publish. A separate `report` job, which never runs scanner code and only reads the summaries, signs a cosign `vuln` attestation on each index digest in both registries, writes `reports/index.json` and `reports/images/.json` to the `security-reports` branch (only this job writes it; the docs security status page reads it), and opens a public `security-scan` issue for each fixable critical or high CVE found in two scans in a row, at most 10 new per scan. Issues update in place and close when a scan no longer finds them. `scripts/scan-summary.sh` reduces Grype's JSON and `scripts/security-report.sh` builds the reports; both are checked by `scripts/tests/run.sh`. +- `service_scan-images.yml` runs after every production publish (and by hand for the latest one). Grype scans the `linux/amd64` image of every published tag, pulled anonymously from GHCR. The scan job has no token and no registry credentials, and Grype runs in its own container, so a compromised scanner has nothing to steal or publish. Every image's counts go to the run summary, which is public. Findings with a fix become code scanning alerts: the `alerts` job (the only one with `security-events: write`, and it never runs Grype) uploads one SARIF run per variation, category `grype/`, always against `main` so release and weekly scans share one alert list. Code scanning allows 20 runs per SARIF file, hence one merged run per variation. Grype gives every image the same file path, which would merge alerts across images, so each finding's path and fingerprint are rewritten under `-`, which also keeps an alert stable across PHP patches. - The build matrix is generated from the PHP version pipeline described above. - `scheduled-task_watch-php-releases.yml` runs hourly, resolves the active patches with `scripts/get-php-versions.sh`, and starts a production run when a patch's `cli` tag on its first OS is missing from Docker Hub, so new PHP patches publish within about an hour of php.net listing them. A Docker Hub lookup that is neither found nor not found is skipped, and no run starts if a production run started in the last 6 hours. Like the weekly rebuild (Tuesday 08:00 UTC, which delivers OS package updates), it only moves floating tags; `-vX.Y.Z` tags come only from the GitHub release. - Dependencies are monitored by Renovate (`renovate.json`), which replaced Dependabot. It reads the `# renovate:` comments in the base config and `service_lint.yml`, the `nginx_version` lines (from the nginx.org package listings), docker-library/php `master` (weekly, merged automatically once every check passes; every other pin waits for a manual merge), GitHub Actions, and one docs npm package. Every action is pinned to a full commit SHA with its version in a comment (`helpers:pinGitHubActionDigests`), and releases from GitHub and Docker datasources wait 3 days (`minimumReleaseAge`) before Renovate proposes them, so a hijacked or broken release is usually caught first. Merged bumps reach the published images with the next release; PHP patches need no release because they are resolved from php.net at run time. diff --git a/SECURITY.md b/SECURITY.md index 29d82e02..eb4b0acd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ Vulnerabilities in what *we* author and ship: - Entrypoint scripts, healthchecks, helper scripts, and S6 service definitions in `src/` - Default configs we ship for PHP-FPM, NGINX, Apache, and FrankenPHP - Insecure defaults: ports, file permissions, the `www-data` user model, SSL defaults -- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing, image signing and attestations) +- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing, image signing) - Documentation that materially misleads users about a security-relevant setting ## What is out of scope โ€” report to the upstream project @@ -40,7 +40,7 @@ If an upstream CVE is already publicly disclosed, you don't need to file with us Images we publish are signed with cosign and carry an SBOM and build provenance. Images published before signing was added have provenance only. [Verifying images](https://serversideup.net/open-source/docker-php/docs/guide/verifying-images) shows how to check them. -Each production build ends with a vulnerability scan of every published image. The results are on the [security status](https://serversideup.net/open-source/docker-php/docs/reference/security-status) page. Scanner findings are already disclosed upstream CVEs, so we track the fixable ones that survive a rebuild in public issues labeled [`security-scan`](https://github.com/serversideup/docker-php/issues?q=label%3Asecurity-scan). Anything that is not yet public, including a problem in what we author, still goes through [private reporting](#reporting-a-vulnerability). +Each production build ends with a vulnerability scan of every published image. The results are in the run summary of each [production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml), and [Vulnerability scanning](https://serversideup.net/open-source/docker-php/docs/reference/vulnerability-scanning) explains how to read them. Scanner findings are already disclosed upstream CVEs. Anything that is not yet public, including a problem in what we author, still goes through [private reporting](#reporting-a-vulnerability). ## Component lifecycle references diff --git a/docs/app/components/SecurityStatus.vue b/docs/app/components/SecurityStatus.vue deleted file mode 100644 index c476036b..00000000 --- a/docs/app/components/SecurityStatus.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - diff --git a/docs/content/docs/5.guide/7.verifying-images.md b/docs/content/docs/5.guide/7.verifying-images.md index d96ed615..5288947b 100644 --- a/docs/content/docs/5.guide/7.verifying-images.md +++ b/docs/content/docs/5.guide/7.verifying-images.md @@ -66,28 +66,6 @@ docker buildx imagetools inspect serversideup/php:8.5-cli \ } ``` -## Verify the vulnerability scan -After every production build, we scan each published image and attach the results to it as a signed `vuln` attestation. The scan workflow signs it with its own identity. Verify it against that workflow: - -```bash [Terminal] -cosign verify-attestation serversideup/php:8.5-cli \ - --type vuln \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_scan-images\.yml@refs/(heads/main|tags/v.+)$' -``` - -cosign prints one JSON line per attestation, with the scan in a base64 `payload`. To see the counts per severity and how many of them have a fix: - -```bash [Terminal] -cosign verify-attestation serversideup/php:8.5-cli \ - --type vuln \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_scan-images\.yml@refs/(heads/main|tags/v.+)$' \ - | jq -r .payload | base64 -d | jq '.predicate.scanner.result | {scanned_at, counts, fixable}' -``` - -The same results for every image are on the [security status](/docs/reference/security-status) page, which also explains how to read them. - ## Scan it yourself You don't have to trust our scan. The images are public and work with any scanner. For example, with [Grype](https://github.com/anchore/grype): @@ -95,4 +73,4 @@ You don't have to trust our scan. The images are public and work with any scanne grype serversideup/php:8.5-cli ``` -Add `--only-fixed` to list only the findings that have a fix available. Those are the ones a rebuild or an upgrade can change. See [fixable and unfixed findings](/docs/reference/security-status#most-findings-have-no-fix-yet) for why the full list is usually much longer. +Add `--only-fixed` to list only the findings that have a fix available. Those are the ones a rebuild or an upgrade can change. See [fixable and unfixed findings](/docs/reference/vulnerability-scanning#most-findings-have-no-fix-yet) for why the full list is usually much longer. diff --git a/docs/content/docs/8.reference/3.security-status.md b/docs/content/docs/8.reference/3.security-status.md deleted file mode 100644 index 74b5ba5f..00000000 --- a/docs/content/docs/8.reference/3.security-status.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -head.title: 'Security Status - Docker PHP - Server Side Up' -description: 'Known vulnerabilities in every published serversideup/php image, from the scan that runs after each production build.' -layout: docs -title: Security status ---- - -::lead-p -This page shows the known vulnerabilities in every image we publish. It updates after each scan. The numbers to watch are the fixable ones. -:: - -## When and how images are scanned -A scan runs after every production publish. That covers each release, the Tuesday 08:00 UTC weekly rebuild, and the runs that ship new PHP patches. [Grype](https://github.com/anchore/grype) scans the `linux/amd64` image of every published tag, pulled from GitHub Packages. - -The scan job has read-only access and no registry credentials. Grype runs in its own container. A separate job that never runs scanner code signs the results and publishes them. - -Each image's results are also attached to it as a signed attestation. [Verifying images](/docs/guide/verifying-images#verify-the-vulnerability-scan) shows how to check it. - -## Most findings have no fix yet -A scanner matches every package in the image against public CVE databases. Many of the CVEs it reports are ones the distribution has marked "not fixed" or "won't fix". For those, there is no newer package to install. A typical Debian image shows a large number of these. A scan of `8.5-cli-bookworm` found 18 critical and 144 high CVEs. None of them had a fix. - -No rebuild can change an unfixed finding. The fixable count is the meaningful number: findings where a newer package exists. The weekly rebuild picks those up on its own. The docker-library project explains this in more depth in [Why does my security scanner show that an image has CVEs?](https://github.com/docker-library/faq#why-does-my-security-scanner-show-that-an-image-has-cves) - -## Fixable findings that survive a rebuild become issues -A critical or high CVE that has a fix and shows up in two scans in a row survived a rebuild. Something is holding it back. Each one gets a public GitHub issue labeled [`security-scan`](https://github.com/serversideup/docker-php/issues?q=label%3Asecurity-scan), assigned to the maintainer. - -- An issue updates in place on every scan and closes itself once a scan no longer finds the CVE. -- A scan opens at most 10 new issues. The rest wait for the next scan. - -These issues track CVEs that are already public upstream. To report a problem in our own scripts, configs, or workflows, use [private vulnerability reporting](https://github.com/serversideup/docker-php/security/policy) instead. - -## Current results - -::security-status -:: - -## Read the results from your own tools -The data behind this table is public JSON on the `security-reports` branch: - -```text [URL] -https://raw.githubusercontent.com/serversideup/docker-php/security-reports/reports/index.json -``` - -- `reports/index.json` lists every image with its counts per severity and how many of each are fixable. Its `fixable` list holds each fixable critical or high finding, with the images it affects and `consecutive_scans`, the number of scans in a row that found it. -- `reports/images/.json`, such as `reports/images/8.5.12-cli-bookworm.json`, holds one image's critical, high, and medium findings. Low and negligible findings are only counted. diff --git a/docs/content/docs/8.reference/3.vulnerability-scanning.md b/docs/content/docs/8.reference/3.vulnerability-scanning.md new file mode 100644 index 00000000..a1cd255b --- /dev/null +++ b/docs/content/docs/8.reference/3.vulnerability-scanning.md @@ -0,0 +1,34 @@ +--- +head.title: 'Vulnerability Scanning - Docker PHP - Server Side Up' +description: 'How every published serversideup/php image is scanned for known vulnerabilities, and where to see the results.' +layout: docs +title: Vulnerability scanning +--- + +::lead-p +Every image we publish is scanned for known vulnerabilities after each production build. The results are public, and the numbers to watch are the fixable ones. +:: + +## When and how images are scanned +A scan runs after every production publish. That covers each release, the Tuesday 08:00 UTC weekly rebuild, and the runs that ship new PHP patches. [Grype](https://github.com/anchore/grype) scans the `linux/amd64` image of every published tag, pulled from GitHub Packages. + +The scan job has no access token and no registry credentials, and Grype runs in its own container. A compromised scanner would have nothing to steal and nothing it could publish. + +## See the latest results +Open the [latest successful production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml?query=is%3Asuccess). Its summary page has a table for each variation with every image's critical, high, and medium counts, and how many findings have a fix. + +Findings that have a fix also become code scanning alerts for the maintainers. Each one closes once a scan no longer finds it. + +## Most findings have no fix yet +A scanner matches every package in the image against public CVE databases. Many of the CVEs it reports are ones the distribution has marked "not fixed" or "won't fix", so there is no newer package to install. A scan of `8.5-cli-bookworm` found 18 critical and 144 high CVEs, and none of them had a fix. + +No rebuild can change an unfixed finding. The fixable count is the meaningful number. The docker-library project explains this in more depth in [Why does my security scanner show that an image has CVEs?](https://github.com/docker-library/faq#why-does-my-security-scanner-show-that-an-image-has-cves) + +## Scan it yourself +You don't have to rely on our scan. The images are public and work with any scanner: + +```bash [Terminal] +grype serversideup/php:8.5-cli --only-fixed +``` + +To report a problem in our own scripts, configs, or workflows, use [private vulnerability reporting](https://github.com/serversideup/docker-php/security/policy) instead. diff --git a/scripts/scan-summary.sh b/scripts/scan-summary.sh deleted file mode 100755 index 76826466..00000000 --- a/scripts/scan-summary.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Usage: scan-summary.sh -# -# Reduces Grype's JSON for one image to the summary the security reports publish (see -# service_scan-images.yml): counts per severity, how many of those have a fix, and the -# critical, high, and medium findings. Low and negligible findings are only counted, -# because Debian lists hundreds of them that it does not plan to fix. - -grype_json="${1:?Usage: scan-summary.sh }" - -jq --arg image "$2" --arg variation "$3" --arg php "$4" --arg os "$5" --arg digest "$6" ' - def severities: ["Critical", "High", "Medium", "Low", "Negligible", "Unknown"]; - def count_by_severity: (reduce .[] as $s ((severities | map({(.): 0}) | add); .[$s] += 1)); - [.matches[] | { - id: .vulnerability.id, - severity: .vulnerability.severity, - package: .artifact.name, - version: .artifact.version, - type: .artifact.type, - fixed_in: (.vulnerability.fix.versions // []), - state: .vulnerability.fix.state, - url: .vulnerability.dataSource - }] as $found - | { - image: $image, - variation: $variation, - php: $php, - os: $os, - digest: $digest, - scanned_at: (.descriptor.timestamp // (now | todate)), - scanner: {name: "grype", version: .descriptor.version, db_built: .descriptor.db.status.built}, - counts: ($found | map(.severity) | count_by_severity), - fixable: ($found | map(select(.state == "fixed") | .severity) | count_by_severity), - vulnerabilities: ($found - | map(select(.severity == "Critical" or .severity == "High" or .severity == "Medium")) - | unique_by([.id, .package, .version]) - | sort_by((.severity as $s | ["Critical", "High", "Medium"] | index($s)), .id)) - }' "$grype_json" diff --git a/scripts/security-report.sh b/scripts/security-report.sh deleted file mode 100755 index b1733b3f..00000000 --- a/scripts/security-report.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Usage: security-report.sh -# -# Turns the per-image summaries from scan-summary.sh into the public security reports -# on the security-reports branch: reports/images/.json for each image and -# reports/index.json for all of them, which the Security status docs page reads. -# -# The index lists every critical or high finding that has a fix, with the number of -# scans in a row that found it. The weekly rebuild clears most of these on its own, -# so service_scan-images.yml only opens an issue once a finding survives a rebuild -# (two scans in a row). The previous index in is how a scan knows what -# the last one found. The fixable list is printed so the workflow can manage issues. - -scans_dir="${1:?Usage: security-report.sh }" -reports_dir="${2:?Usage: security-report.sh }" -run_url="${3:?Usage: security-report.sh }" - -previous='{"fixable": []}' -if [ -f "$reports_dir/index.json" ]; then - previous=$(cat "$reports_dir/index.json") -fi - -scans=$(find "$scans_dir" -name '*.json' -print0 | xargs -0 -r jq -s 'sort_by(.image)') -scans="${scans:-[]}" -if [ "$(echo "$scans" | jq 'length')" = "0" ]; then - echo "No scan summaries found in $scans_dir" >&2 - exit 1 -fi - -# Images that are no longer published (an end-of-life OS, a superseded patch) drop out -# of the reports, so the page only ever describes images people can pull today. -rm -rf "$reports_dir/images" -mkdir -p "$reports_dir/images" -echo "$scans" | jq -c '.[]' | while read -r scan; do - echo "$scan" | jq '.' > "$reports_dir/images/$(echo "$scan" | jq -r .image).json" -done - -echo "$scans" | jq --argjson previous "$previous" --arg run_url "$run_url" ' - ($previous.fixable | map({(.id): .consecutive_scans}) | add // {}) as $seen - | { - generated_at: (now | todate), - run_url: $run_url, - scanner: .[0].scanner, - images: map({image, variation, php, os, digest, scanned_at, counts, fixable}), - fixable: ( - [.[] as $scan | $scan.vulnerabilities[] - | select((.severity == "Critical" or .severity == "High") and .state == "fixed") - | {id, severity, image: $scan.image, package, version, fixed_in}] - | group_by(.id) - | map({ - id: .[0].id, - severity: .[0].severity, - packages: (map(.package) | unique), - images: map({image, package, version, fixed_in}), - consecutive_scans: (($seen[.[0].id] // 0) + 1) - }) - | sort_by((.severity as $s | ["Critical", "High"] | index($s)), .id)) - }' > "$reports_dir/index.json" - -jq '.fixable' "$reports_dir/index.json" diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh index 3479218b..0bbb88f2 100755 --- a/scripts/tests/run.sh +++ b/scripts/tests/run.sh @@ -93,55 +93,6 @@ assert_contains "$matrix_json" '"php_variation":"fpm-nginx","php_extension_overr assert_contains "$matrix_json" '"php_base_tag":"php-base-8.1.34-cli-bookworm","docker_library_ref":"frozen-sha"' "uses a minor version's own docker-library ref" assert_contains "$matrix_json" '"php_variation":"fpm-nginx","php_extension_overrides":"","php_base":"cli","php_base_tag":"php-base-8.6-rc-cli-trixie"' "lets a minor version swap a variation's docker-library base" -# Grype output trimmed to the fields the summary reads: a fixable critical (reported -# twice, as Grype does for one package matched two ways), a high Debian won't fix, and -# a negligible one. -grype_match() { - printf '{"vulnerability":{"id":"%s","severity":"%s","dataSource":"https://example.test/%s","fix":{"versions":%s,"state":"%s"}},"artifact":{"name":"%s","version":"1.0","type":"deb"}}' "$@" -} -grype_scan() { - echo "{\"descriptor\":{\"version\":\"0.121.0\",\"timestamp\":\"2026-10-30T08:00:00Z\",\"db\":{\"status\":{\"built\":\"2026-10-30T06:00:00Z\"}}},\"matches\":[$1]}" -} - -echo -echo "scan-summary.sh" -grype_scan "$(grype_match CVE-1 Critical CVE-1 '["1.1"]' fixed libssl3),$(grype_match CVE-1 Critical CVE-1 '["1.1"]' fixed libssl3),$(grype_match CVE-2 High CVE-2 '[]' wont-fix libsqlite3),$(grype_match CVE-3 Negligible CVE-3 '[]' not-fixed libc6)" > "$image_details/grype.json" -scan=$(bash "$scripts_dir/scan-summary.sh" "$image_details/grype.json" 8.5.12-cli-bookworm cli 8.5.12 bookworm sha256:aaa | jq -c .) -assert_contains "$scan" '"counts":{"Critical":2,"High":1,"Medium":0,"Low":0,"Negligible":1,"Unknown":0}' "counts every match per severity" -assert_contains "$scan" '"fixable":{"Critical":2,"High":0,' "counts the matches that have a fix" -assert_contains "$scan" '"scanner":{"name":"grype","version":"0.121.0","db_built":"2026-10-30T06:00:00Z"}' "records the scanner and database it used" -assert_contains "$(echo "$scan" | jq -c '[.vulnerabilities[].id]')" '["CVE-1","CVE-2"]' "lists one finding per package and leaves negligible ones out" - -echo -echo "security-report.sh" -scans="$image_details/scans" -reports="$image_details/reports" -mkdir -p "$scans" -echo "$scan" > "$scans/8.5.12-cli-bookworm.json" -bash "$scripts_dir/scan-summary.sh" "$image_details/grype.json" 8.4.27-cli-bookworm cli 8.4.27 bookworm sha256:bbb > "$scans/8.4.27-cli-bookworm.json" -first=$(bash "$scripts_dir/security-report.sh" "$scans" "$reports" https://example.test/run/1 | jq -c .) -assert_contains "$first" '"id":"CVE-1","severity":"Critical","packages":["libssl3"]' "lists fixable critical findings" -assert_contains "$first" '"consecutive_scans":1' "counts a new finding as seen once" -assert_contains "$(echo "$first" | jq -c '[.[].id]')" '["CVE-1"]' "leaves findings without a fix out of the fixable list" -assert_contains "$(jq -c '[.images[].image]' "$reports/index.json")" '["8.4.27-cli-bookworm","8.5.12-cli-bookworm"]' "indexes every scanned image" -assert_contains "$(ls "$reports/images")" "8.5.12-cli-bookworm.json" "writes a report per image" - -second=$(bash "$scripts_dir/security-report.sh" "$scans" "$reports" https://example.test/run/2 | jq -c .) -assert_contains "$second" '"consecutive_scans":2' "counts a finding that survived a rebuild" - -rm "$scans/8.4.27-cli-bookworm.json" -grype_scan "$(grype_match CVE-2 High CVE-2 '[]' wont-fix libsqlite3)" > "$image_details/grype.json" -bash "$scripts_dir/scan-summary.sh" "$image_details/grype.json" 8.5.12-cli-bookworm cli 8.5.12 bookworm sha256:aaa > "$scans/8.5.12-cli-bookworm.json" -third=$(bash "$scripts_dir/security-report.sh" "$scans" "$reports" https://example.test/run/3 | jq -c .) -assert_contains "$third" '[]' "drops a finding the rebuild fixed" -assert_contains "$(ls "$reports/images")" "8.5.12-cli-bookworm.json" "keeps reports for images still published" -if [ -e "$reports/images/8.4.27-cli-bookworm.json" ]; then - echo "โŒ removes reports for images no longer published" - failures=$((failures + 1)) -else - echo "โœ… removes reports for images no longer published" -fi - echo if [ "$failures" -gt 0 ]; then echo "$failures check(s) failed" >&2