diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml index d741829d..353c3ab7 100644 --- a/.github/workflows/action_publish-images-beta.yml +++ b/.github/workflows/action_publish-images-beta.yml @@ -83,7 +83,6 @@ jobs: registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php" tag-prefix: "beta" release-type: "testing" - secrets: inherit test: needs: [release, setup, base, build] @@ -106,7 +105,9 @@ jobs: id-token: write with: ref: ${{ needs.release.outputs.tag }} - secrets: inherit + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} report: needs: [release, setup, build, test, publish] diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml index df154724..0c1f81c0 100644 --- a/.github/workflows/action_publish-images-production.yml +++ b/.github/workflows/action_publish-images-production.yml @@ -3,11 +3,17 @@ run-name: >- Production images (${{ github.event.release.tag_name || (github.event_name == 'schedule' && 'weekly rebuild') || + inputs.reason || 'manual rebuild' }}) on: workflow_dispatch: + inputs: + reason: + description: 'Shown in the run title (example: PHP 8.5.13 release). Left empty, the run is a "manual rebuild".' + type: string + required: false release: types: [released] schedule: @@ -83,7 +89,6 @@ jobs: registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php" tag-prefix: '' release-type: "latest" - secrets: inherit test: needs: [release, setup, base, build] @@ -106,7 +111,19 @@ jobs: id-token: write with: ref: ${{ needs.release.outputs.tag }} - secrets: inherit + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + # 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: read + security-events: write report: needs: [release, setup, build, test, publish] diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml index e64c7bd3..c79bcb81 100644 --- a/.github/workflows/action_publish-images-prs.yml +++ b/.github/workflows/action_publish-images-prs.yml @@ -81,7 +81,6 @@ jobs: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }} - secrets: inherit test: needs: [setup, base, build] @@ -105,7 +104,9 @@ jobs: with: ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }} authenticate_with_ghcr: false - secrets: inherit + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} report: needs: [setup, build, test, publish] diff --git a/.github/workflows/action_update-dockerhub-readme.yml b/.github/workflows/action_update-dockerhub-readme.yml index 10fca484..3ff986ef 100644 --- a/.github/workflows/action_update-dockerhub-readme.yml +++ b/.github/workflows/action_update-dockerhub-readme.yml @@ -8,18 +8,23 @@ on: paths: - README.md +permissions: {} + jobs: update_container_readme: runs-on: ubuntu-24.04 name: Push README to Docker Hub + permissions: + contents: read steps: - name: git checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: main - name: push README to Dockerhub - uses: peter-evans/dockerhub-description@v5 + uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 with: username: ${{ secrets.DOCKER_HUB_README_USERNAME }} password: ${{ secrets.DOCKER_HUB_README_PASSWORD }} diff --git a/.github/workflows/scheduled-task_scorecard.yml b/.github/workflows/scheduled-task_scorecard.yml new file mode 100644 index 00000000..7b40d0bc --- /dev/null +++ b/.github/workflows/scheduled-task_scorecard.yml @@ -0,0 +1,46 @@ +name: OpenSSF Scorecard + +# Scores the repository's supply chain practices (pinned dependencies, token permissions, +# signed releases, branch protection, and more) and publishes the result to +# scorecard.dev, which serves the badge in the README. Publishing only works while this +# workflow follows Scorecard's rules: no workflow-level env, defaults, or write +# permissions, and only Scorecard's approved actions in the job. +on: + workflow_dispatch: + branch_protection_rule: + push: + branches: + - main + schedule: + - cron: '41 6 * * 3' + +permissions: {} + +jobs: + analysis: + # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream. + if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read + security-events: write + id-token: write + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload the results to the Security tab + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/scheduled-task_update-readme.yml b/.github/workflows/scheduled-task_update-readme.yml index c389c3b2..fc48fa72 100644 --- a/.github/workflows/scheduled-task_update-readme.yml +++ b/.github/workflows/scheduled-task_update-readme.yml @@ -7,6 +7,6 @@ jobs: update-readme: # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream. if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php' - uses: serversideup/.github/.github/workflows/update-readme.yml@master + uses: serversideup/.github/.github/workflows/update-readme.yml@0cc5de42120438871a6c36a91b6a7dc2a15dbd7f # v1.0.1 permissions: contents: write diff --git a/.github/workflows/scheduled-task_watch-php-releases.yml b/.github/workflows/scheduled-task_watch-php-releases.yml new file mode 100644 index 00000000..26314bf0 --- /dev/null +++ b/.github/workflows/scheduled-task_watch-php-releases.yml @@ -0,0 +1,74 @@ +name: Watch for PHP releases + +# PHP is compiled from php.net, so a new patch can ship as soon as php.net lists it. +# Every hour this resolves the active patches the same way a build does, and starts a +# production run when one of them has no image on Docker Hub yet. Like the weekly +# rebuild, that run rebuilds the latest release and only moves the floating tags. +on: + workflow_dispatch: + schedule: + - cron: '17 * * * *' + +permissions: {} + +jobs: + watch: + # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream. + if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PRODUCTION_WORKFLOW: action_publish-images-production.yml + DOCKER_HUB_REPOSITORY: serversideup/php + # A run that started this recently either is still going or already opened a ci-failure issue. + COOLDOWN_HOURS: 6 + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve the active PHP patches + run: ./scripts/get-php-versions.sh + + # Each patch is looked up as its cli tag on the first OS it builds for. Anything other + # than "found" or "not found" is skipped, so a Docker Hub outage never starts a run. + - name: Find patches that have no image yet + id: missing + run: | + MISSING=() + while IFS=$'\t' read -r PATCH OS; do + TAG="${PATCH}-cli-${OS}" + STATUS=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 \ + "https://hub.docker.com/v2/namespaces/${DOCKER_HUB_REPOSITORY%/*}/repositories/${DOCKER_HUB_REPOSITORY#*/}/tags/${TAG}") + case "$STATUS" in + 200) echo "$PATCH is published ($TAG)" ;; + 404) echo "$PATCH has no image yet ($TAG)"; MISSING+=("$PATCH") ;; + *) echo "::warning title=Docker Hub lookup failed::$TAG returned HTTP $STATUS, skipping $PATCH this hour" ;; + esac + done < <(yq -o=json scripts/conf/php-versions.yml | jq -r ' + .php_versions[].minor_versions[] + | .base_os[0].name as $os + | .patch_versions[] | select(test("rc") | not) + | [., $os] | @tsv') + echo "patches=${MISSING[*]}" >> "$GITHUB_OUTPUT" + + - name: Start a production run + if: steps.missing.outputs.patches != '' + env: + PATCHES: ${{ steps.missing.outputs.patches }} + run: | + RECENT=$(gh run list --workflow "$PRODUCTION_WORKFLOW" --limit 10 --json status,createdAt,url \ + | jq -r --argjson hours "$COOLDOWN_HOURS" \ + '[.[] | select(.status != "completed" or ((.createdAt | fromdateiso8601) > (now - $hours * 3600)))][0].url // ""') + if [ -n "$RECENT" ]; then + echo "::notice title=Already building::$RECENT started in the last ${COOLDOWN_HOURS} hours, so no new run was started for PHP $PATCHES." + exit 0 + fi + gh workflow run "$PRODUCTION_WORKFLOW" -f reason="PHP ${PATCHES// /, } release" + echo "::notice title=Production run started::PHP $PATCHES has no published image yet." diff --git a/.github/workflows/service_build-images.yml b/.github/workflows/service_build-images.yml index aae69514..4f1cb280 100644 --- a/.github/workflows/service_build-images.yml +++ b/.github/workflows/service_build-images.yml @@ -59,21 +59,24 @@ jobs: steps: - name: Check out code. - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Download PHP Versions file - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: php-versions.yml path: ./artifacts - name: Move PHP Versions file - run: mv ./artifacts/php-versions.yml ${{ inputs.php-versions-file }} + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} + run: mv ./artifacts/php-versions.yml "$PHP_VERSIONS_FILE" - name: Set up Depot CLI - uses: depot/setup-action@v1 + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 - name: "📦 Assemble the Docker Tags" run: | @@ -92,35 +95,43 @@ jobs: - name: Set REPOSITORY_BUILD_VERSION id: set_version + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + INPUT_REF: ${{ inputs.ref }} + RUN_ID: ${{ github.run_id }} run: | - if [[ "${{ github.ref_type }}" == "tag" || "${{ inputs.ref }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "$REF_TYPE" == "tag" || "$INPUT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then # Building from a tag (release or scheduled task) echo "🚀 Setting REPOSITORY_BUILD_VERSION to Tag + Run ID" - if [[ "${{ github.ref_type }}" == "tag" ]]; then - VERSION="${{ github.ref_name }}" + if [[ "$REF_TYPE" == "tag" ]]; then + VERSION="$REF_NAME" else - VERSION="${{ inputs.ref }}" + VERSION="$INPUT_REF" fi - echo "REPOSITORY_BUILD_VERSION=${VERSION}-${{ github.run_id }}" >> "$GITHUB_ENV" + echo "REPOSITORY_BUILD_VERSION=${VERSION}-${RUN_ID}" >> "$GITHUB_ENV" else # Regular git build (PR, branch push, etc). Read the SHA from the checkout so # builds dispatched for a PR number report the PR's commit, not the dispatched branch. echo "👨‍🔬 Setting REPOSITORY_BUILD_VERSION to Git SHA + Run ID" SHORT_SHA=$(git rev-parse --short=7 HEAD) - echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${{ github.run_id }}" >> "$GITHUB_ENV" + echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${RUN_ID}" >> "$GITHUB_ENV" fi - name: Compute NGINX build-arg (only for fpm-nginx) id: compute_nginx if: ${{ matrix.php_variation == 'fpm-nginx' }} + env: + BASE_OS: ${{ matrix.base_os }} + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} run: | if command -v yq >/dev/null 2>&1; then - VERSION=$(yq -r '.operating_systems[].versions[] | select(.version == "${{ matrix.base_os }}") | .nginx_version' '${{ inputs.php-versions-file }}' | head -n1) + VERSION=$(yq -r '.operating_systems[].versions[] | select(.version == strenv(BASE_OS)) | .nginx_version' "$PHP_VERSIONS_FILE" | head -n1) else - VERSION=$(awk -v key="${{ matrix.base_os }}" 'BEGIN{found=0} $1=="version:" && $2==key {found=1} found && $1=="nginx_version:" {print $2; exit}' "${{ inputs.php-versions-file }}") + VERSION=$(awk -v key="$BASE_OS" 'BEGIN{found=0} $1=="version:" && $2==key {found=1} found && $1=="nginx_version:" {print $2; exit}' "$PHP_VERSIONS_FILE") fi if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then - echo "Unable to determine NGINX version for OS ${{ matrix.base_os }}" 1>&2 + echo "Unable to determine NGINX version for OS $BASE_OS" 1>&2 exit 1 fi echo "nginx_arg=NGINX_VERSION=$VERSION" >> "$GITHUB_OUTPUT" @@ -152,7 +163,7 @@ jobs: # onto the runner for the test step below instead. - name: Build images id: build - uses: depot/build-push-action@v1 + uses: depot/build-push-action@0f27be7c41d76663a98518dfe68f1c46582242c4 # v1.19.0 with: file: src/variations/${{ matrix.php_variation }}/Dockerfile build-args: | @@ -171,6 +182,11 @@ jobs: save: ${{ inputs.publish }} save-tags: ${{ github.run_id }}-${{ env.IMAGE_NAME }} load: ${{ !inputs.publish }} + # Saved images carry an SBOM and full build provenance in their index, so the + # signature service_publish-images.yml puts on the index covers both. An image + # loaded onto the runner cannot hold attestations. + sbom: ${{ inputs.publish }} + provenance: ${{ inputs.publish && 'mode=max' || 'false' }} tags: ${{ env.DOCKER_TAGS }} outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=Supercharge your PHP experience with Docker images that are based off the official PHP images but are optimized to be run in production environments for Laravel and WordPress and more @@ -179,7 +195,9 @@ jobs: # dispatches the workflow for the pull request. - name: Test the image (amd64) if: ${{ !inputs.publish }} - run: bash scripts/test-image.sh "${DOCKER_TAGS%%,*}" "${{ matrix.patch_version }}" + env: + PHP_VERSION: ${{ matrix.patch_version }} + run: bash scripts/test-image.sh "${DOCKER_TAGS%%,*}" "$PHP_VERSION" # The test, publish, and report workflows all read these files. - name: Record image details @@ -187,6 +205,9 @@ jobs: PUBLISH: ${{ inputs.publish }} BUILD_ID: ${{ steps.build.outputs.build-id }} DIGEST: ${{ steps.build.outputs.digest }} + VARIATION: ${{ matrix.php_variation }} + PHP_VERSION: ${{ matrix.patch_version }} + BASE_OS: ${{ matrix.base_os }} run: | mkdir -p image-details SAVED=null @@ -194,9 +215,9 @@ jobs: SAVED="\"registry.depot.dev/$(jq -r .id depot.json):${{ github.run_id }}-${IMAGE_NAME}\"" fi jq -n \ - --arg variation "${{ matrix.php_variation }}" \ - --arg php "${{ matrix.patch_version }}" \ - --arg os "${{ matrix.base_os }}" \ + --arg variation "$VARIATION" \ + --arg php "$PHP_VERSION" \ + --arg os "$BASE_OS" \ --arg tags "$DOCKER_TAGS" \ --arg build_id "$BUILD_ID" \ --arg digest "$DIGEST" \ @@ -205,7 +226,7 @@ jobs: | tee "image-details/${IMAGE_NAME}.json" - name: Upload image details - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: image-details-${{ env.IMAGE_NAME }} path: image-details/ diff --git a/.github/workflows/service_build-php-base.yml b/.github/workflows/service_build-php-base.yml index eabc073a..a0592b5a 100644 --- a/.github/workflows/service_build-php-base.yml +++ b/.github/workflows/service_build-php-base.yml @@ -37,12 +37,13 @@ jobs: matrix: ${{ fromJson(inputs.matrix) }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Set up Depot CLI - uses: depot/setup-action@v1 + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 # The context lives inside the checkout so Depot finds depot.json above it. - name: Prepare the docker-library/php build context @@ -58,7 +59,7 @@ jobs: # Brotli, OpenSSL) in its first layers, and a cached layer would keep shipping old # versions until Debian or Alpine publish a new base image. - name: Compile and save the PHP base image - uses: depot/build-push-action@v1 + uses: depot/build-push-action@0f27be7c41d76663a98518dfe68f1c46582242c4 # v1.19.0 with: context: .php-base/${{ matrix.php_base_tag }} platforms: | diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml index 1e9b8012..22c3ec35 100644 --- a/.github/workflows/service_lint.yml +++ b/.github/workflows/service_lint.yml @@ -12,6 +12,8 @@ env: SHELLCHECK_VERSION: v0.11.0 # renovate: datasource=github-releases depName=hadolint/hadolint HADOLINT_VERSION: v2.15.1 + # renovate: datasource=github-releases depName=zizmorcore/zizmor + ZIZMOR_VERSION: 1.31.1 jobs: actionlint: @@ -21,11 +23,32 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint GitHub Actions workflows run: docker run --rm -v "$PWD:/repo" -w /repo "rhysd/actionlint:${ACTIONLINT_VERSION}" -color + # Security audit for the workflows: unpinned actions, template injection, risky triggers, + # and broad permissions. Its project decisions are recorded in .github/zizmor.yml. The + # token is read-only and lets it check that every pinned SHA belongs to its repository. + zizmor: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Audit GitHub Actions workflows + env: + GH_TOKEN: ${{ github.token }} + run: docker run --rm -e GH_TOKEN -v "$PWD:/repo:ro" -w /repo "ghcr.io/zizmorcore/zizmor:${ZIZMOR_VERSION}" --color=always .github/workflows + shellcheck: runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -33,7 +56,9 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint shell scripts run: | @@ -56,7 +81,9 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Lint Dockerfiles run: docker run --rm -v "$PWD:/repo" -w /repo "hadolint/hadolint:${HADOLINT_VERSION}" hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile @@ -69,7 +96,9 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Runs on a real runner on purpose: tool versions here (jq, yq) are what the # publish workflows use, and they differ from laptops. diff --git a/.github/workflows/service_publish-images.yml b/.github/workflows/service_publish-images.yml index 1da0903e..ec83625a 100644 --- a/.github/workflows/service_publish-images.yml +++ b/.github/workflows/service_publish-images.yml @@ -3,7 +3,9 @@ name: Publish Images # Phase 3 of 3: promote the exact images that passed the image tests from the Depot # Registry to Docker Hub and GHCR. No rebuild happens here and no image bytes pass # through the runner: `depot push` copies each saved build to every tag server-side, -# and the published digest is checked against the one the build recorded. Depot +# the published digest is checked against the one the build recorded, and each +# registry's copy is signed with cosign. Signing is keyless: the signature is tied to +# this workflow through its OIDC token, so there is no signing key to store or leak. Depot # sponsors this project, so its runners are used for the phases that do real work. on: workflow_call: @@ -16,6 +18,11 @@ on: type: string default: ${{ github.ref }} description: 'The git ref to checkout (branch, tag, or commit SHA)' + secrets: + DOCKER_HUB_USERNAME: + required: true + DOCKER_HUB_ACCESS_TOKEN: + required: true jobs: list: @@ -29,7 +36,7 @@ jobs: count: ${{ steps.list.outputs.count }} steps: - name: Download image details from every build job - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: image-details-* path: image-details @@ -63,29 +70,33 @@ jobs: matrix: ${{ fromJson(needs.list.outputs.matrix) }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Set up Depot CLI - uses: depot/setup-action@v1 + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 with: oidc: true - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 if: ${{ inputs.authenticate_with_ghcr }} with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + # `depot push` handles one tag at a time, so images are pushed in parallel and each # one's output is printed afterwards as its own log group. Every image is attempted # even after one fails, so a run shows every image that could not be published at once. @@ -104,6 +115,21 @@ jobs: PROJECT=$(jq -r .id depot.json) mkdir -p image-details logs + retry() { + local description="$1" attempt=1 + shift + until "$@"; do + if [ "$attempt" -ge "$PUSH_ATTEMPTS" ]; then + echo "::error title=Publish failed::${description} failed after ${PUSH_ATTEMPTS} attempts" + return 1 + fi + local pause=$(( 15 * attempt + RANDOM % 15 )) + echo "${description}: attempt ${attempt} of ${PUSH_ATTEMPTS} failed, retrying in ${pause}s" + sleep "$pause" + attempt=$(( attempt + 1 )) + done + } + publish_image() { local php="$1" os="$2" saved="$3" build_id="$4" digest="$5" tags="$6" local image_name="${VARIATION}-${php}-${os}" @@ -112,17 +138,7 @@ jobs: tag_args+=(--tag "$tag") done echo "Pushing build $build_id to $(( ${#tag_args[@]} / 2 )) tags" - local attempt=1 - until depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id"; do - if [ "$attempt" -ge "$PUSH_ATTEMPTS" ]; then - echo "::error title=Publish failed::${image_name} could not be pushed after ${PUSH_ATTEMPTS} attempts" - return 1 - fi - local pause=$(( 15 * attempt + RANDOM % 15 )) - echo "Push attempt ${attempt} of ${PUSH_ATTEMPTS} failed, retrying in ${pause}s" - sleep "$pause" - attempt=$(( attempt + 1 )) - done + retry "Pushing ${image_name}" depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id" || return 1 # What was published must be what was tested: the tested digest has to appear # in the published index (as the index itself or one of its manifests). @@ -138,6 +154,13 @@ jobs: echo "Published digest matches the tested image ($digest)" fi + # Every tag in a registry points at the same index, so one signature per registry covers them all. + local index_digest repository + index_digest=$(docker buildx imagetools inspect "$canonical_tag" --format '{{json .Manifest.Digest}}' | jq -r .) + for repository in $(tr ',' '\n' <<< "$tags" | sed 's/:[^:]*$//' | sort -u); do + retry "Signing ${repository}@${index_digest}" cosign sign --yes "${repository}@${index_digest}" || return 1 + done + local manifest sizes manifest=$(docker manifest inspect -v "$canonical_tag") || manifest='[]' sizes=$(echo "$manifest" | jq ' @@ -187,7 +210,7 @@ jobs: - name: Record the published images if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: image-details-published-${{ matrix.variation }} path: image-details/ diff --git a/.github/workflows/service_report.yml b/.github/workflows/service_report.yml index f57fb233..0d6cd1e1 100644 --- a/.github/workflows/service_report.yml +++ b/.github/workflows/service_report.yml @@ -29,12 +29,13 @@ jobs: markdown: ${{ steps.render.outputs.markdown }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Download image details from every build job - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: image-details-* path: image-details diff --git a/.github/workflows/service_scan-images.yml b/.github/workflows/service_scan-images.yml new file mode 100644 index 00000000..89203bbf --- /dev/null +++ b/.github/workflows/service_scan-images.yml @@ -0,0 +1,212 @@ +name: Scan Images + +# 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: 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: + run-id: + type: string + default: '' + description: 'The production run whose published images to scan. Defaults to the calling run.' + workflow_dispatch: + inputs: + run-id: + type: string + required: false + description: 'The production run whose published images to scan. Left empty, the latest successful one.' + +permissions: {} + +env: + # renovate: datasource=docker depName=anchore/grype + GRYPE_VERSION: v0.121.0@sha256:621d56d1d68106b787022c81546f7e0a36202a25d1239f0c20195fab8c16f7ea + SCAN_REPOSITORY: ghcr.io/serversideup/php + SCAN_PLATFORM: linux/amd64 + +jobs: + plan: + runs-on: ubuntu-24.04 + 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 + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + INPUT_RUN_ID: ${{ inputs.run-id }} + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + # Called from a production run, the run itself. Started by hand, the latest successful one. + RUN_ID="$INPUT_RUN_ID" + if [ -z "$RUN_ID" ] && [[ "$WORKFLOW_REF" == *"/service_scan-images.yml@"* ]]; then + 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 + with: + pattern: image-details-published-* + path: image-details + merge-multiple: true + run-id: ${{ steps.run.outputs.id }} + github-token: ${{ github.token }} + + - name: Group the images by variation + id: list + 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({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\":[]\}}" + COUNT=$(echo "$MATRIX" | jq '[.include[].images | fromjson | length] | add // 0') + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "$COUNT images to scan" + + scan: + needs: plan + if: needs.plan.outputs.count > 0 + name: scan ${{ matrix.variation }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: {} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + steps: + # 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 + + # 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 raw fixable + chmod 777 raw + + scan_image() { + 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 -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" "$TAG"; do + if [ "$attempt" -ge "$SCAN_ATTEMPTS" ]; then + echo "::error title=Scan failed::${IMAGE} could not be scanned after ${SCAN_ATTEMPTS} attempts" + FAILED+=("$IMAGE") + break + fi + sleep $(( 10 * attempt )) + attempt=$(( attempt + 1 )) + done + 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') + + if [ "${#FAILED[@]}" -gt 0 ]; then + echo "::error title=Scan failed::${FAILED[*]} were not scanned" + exit 1 + fi + + # 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: sarif-${{ matrix.variation }} + path: ${{ matrix.variation }}.sarif + retention-days: 7 + + alerts: + needs: [plan, scan] + if: needs.scan.result == 'success' + name: alerts ${{ matrix.variation }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + security-events: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + steps: + - name: Download the findings + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: sarif-${{ matrix.variation }} + + - name: Update the code scanning alerts + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + sarif_file: ${{ matrix.variation }}.sarif + category: grype/${{ matrix.variation }} + ref: refs/heads/main + sha: ${{ needs.plan.outputs.main-sha }} diff --git a/.github/workflows/service_setup-matrix.yml b/.github/workflows/service_setup-matrix.yml index 6b116ec8..cdaced6f 100644 --- a/.github/workflows/service_setup-matrix.yml +++ b/.github/workflows/service_setup-matrix.yml @@ -34,8 +34,9 @@ jobs: base-matrix: ${{ steps.matrix.outputs.base-matrix }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Prepare PHP versions for the matrix. 😎 @@ -46,17 +47,21 @@ jobs: SKIP_DOWNLOAD: false - name: Ensure our PHP Versions file exists. + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} run: | - if [ ! -f "${{ inputs.php-versions-file }}" ]; then - echo "::error::PHP Versions file does not exist at ${{ inputs.php-versions-file }}" + if [ ! -f "$PHP_VERSIONS_FILE" ]; then + echo "::error::PHP Versions file does not exist at $PHP_VERSIONS_FILE" exit 1 fi - cat "${{ inputs.php-versions-file }}" + cat "$PHP_VERSIONS_FILE" - name: Assemble PHP versions into the matrix. 😎 id: matrix + env: + PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }} run: | - FULL_MATRIX=$(bash ./scripts/generate-matrix.sh '${{ inputs.php-versions-file }}') + FULL_MATRIX=$(bash ./scripts/generate-matrix.sh "$PHP_VERSIONS_FILE") # Group the matrix by variation so each variation becomes its own job group in the GitHub UI. MATRIX_BY_VARIATION=$(echo "$FULL_MATRIX" | jq -c ' @@ -80,13 +85,13 @@ jobs: echo "" echo "| PHP | Resolved patch version | Base OS |" echo "|---|---|---|" - yq -o=json '${{ inputs.php-versions-file }}' | jq -r ' + yq -o=json "$PHP_VERSIONS_FILE" | jq -r ' .php_versions[].minor_versions[] | "| \(.minor) | \(.patch_versions | join(", ")) | \(.base_os | map(.name) | join(", ")) |"' } >> "$GITHUB_STEP_SUMMARY" - name: Upload the php-versions.yml file - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: php-versions.yml path: ${{ inputs.php-versions-file }} diff --git a/.github/workflows/service_test-images.yml b/.github/workflows/service_test-images.yml index cd00ea31..bd2cd903 100644 --- a/.github/workflows/service_test-images.yml +++ b/.github/workflows/service_test-images.yml @@ -26,7 +26,7 @@ jobs: count: ${{ steps.list.outputs.count }} steps: - name: Download image details from every build job - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: image-details-* path: image-details @@ -65,12 +65,13 @@ jobs: matrix: ${{ fromJson(needs.list.outputs.matrix) }} steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ inputs.ref }} - name: Set up Depot CLI - uses: depot/setup-action@v1 + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 with: oidc: true @@ -83,10 +84,12 @@ jobs: env: IMAGES: ${{ matrix.images }} PHP: ${{ matrix.php }} + OS: ${{ matrix.os }} + ARCH: ${{ matrix.arch }} run: | FAILED=() while IFS=$'\t' read -r VARIATION SAVED; do - echo "::group::${VARIATION} ${PHP}-${{ matrix.os }} ${{ matrix.arch }}" + echo "::group::${VARIATION} ${PHP}-${OS} ${ARCH}" if ! { docker pull --quiet "$SAVED" && bash scripts/test-image.sh "$SAVED" "$PHP"; }; then FAILED+=("$VARIATION") fi @@ -94,6 +97,6 @@ jobs: done < <(echo "$IMAGES" | jq -r '.[] | [.variation, .saved] | @tsv') if [ "${#FAILED[@]}" -gt 0 ]; then - echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${{ matrix.os }} on ${{ matrix.arch }}" + echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${OS} on ${ARCH}" exit 1 fi diff --git a/.github/workflows/trigger_auto-retry-failed-builds.yml b/.github/workflows/trigger_auto-retry-failed-builds.yml index 045e8dab..e82ed8cf 100644 --- a/.github/workflows/trigger_auto-retry-failed-builds.yml +++ b/.github/workflows/trigger_auto-retry-failed-builds.yml @@ -8,8 +8,11 @@ name: Auto-retry failed image builds # # Runs are found by workflow file, because `workflow_run.name` holds the run's title # (`run-name:`), not the workflow's name. +# +# workflow_run is safe here: this never checks out or runs code from the run it reacts +# to, and only reads that run's metadata and logs. on: - workflow_run: + workflow_run: # zizmor: ignore[dangerous-triggers] workflows: - "Docker Publish (Production Images)" - "Docker Publish (Beta Images)" diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..d9628331 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,6 @@ +# zizmor configuration for .github/workflows +# Each disabled rule is a deliberate project decision, not an oversight. +rules: + # Its fix, `uses: $/...`, is rejected by actionlint 1.7.12, which also lints these workflows. + self-repository: + disable: true diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md index adf2719f..859b1740 100644 --- a/.oss-scanner/threat_model.md +++ b/.oss-scanner/threat_model.md @@ -6,6 +6,10 @@ serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-ap Untrusted input: - **HTTP requests** to the NGINX, Apache, and Caddy (FrankenPHP) configs we ship. Assume any client on the network can send them. This includes headers that reach PHP or our real-IP handling (`TRUSTED_PROXY`), requests for `/healthcheck`, PHP-FPM status paths, `/storage/*.php`, dotfiles such as `.env` and `.git`, path info after `index.php`, and plain-HTTP requests when `SSL_MODE` is `mixed` or `full`. - **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 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`. Trusted (operators control these): @@ -23,6 +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`, 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 @@ -47,8 +52,8 @@ kill %1 # stops it, like docke `KEY=VALUE` arguments override the image's environment, like `docker run -e`. Every variation listens on 8080 and 8443, so run one at a time or change the port variables. Scripts in `src/` must run under both Debian `dash` and Alpine BusyBox `sh`, so check behavior on `fpm-nginx-alpine` as well. `scripts/tests/run.sh` tests the CI helper scripts. ## 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 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. A downloaded artifact that is used without verification. +- **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 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. @@ -58,4 +63,5 @@ When a finding only applies with a non-default environment variable, rate it one - `COMPOSER_ALLOW_SUPERUSER=1` is intentional for builds that run as root. - `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. +- 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 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 7ed41c0f..0095cd5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,8 +102,11 @@ When modifying the version pipeline, the base config (`php-versions-base-config. - Builds run via GitHub Actions using **Depot** (`depot/build-push-action`) for multi-arch (`linux/amd64` + `linux/arm64/v8`). Depot builds both architectures natively on its own builders; the GitHub runner only orchestrates, so runner size and architecture do not affect build speed. - 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 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. -- 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. 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. +- `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. - Image tags follow the pattern: `serversideup/php:{version}-{variation}` (Debian default) or `serversideup/php:{version}-{variation}-{os}` (Alpine/specific OS). - Fork PRs skip the base phase and build on the official `php:` images, so they can fail in the hours after a PHP release, and they never exercise changes to how PHP is compiled. - Depot authentication: the project ID lives in `depot.json` (not a secret). Same-repo runs authenticate through a Depot OIDC trust relationship (`id-token: write`). Pull requests from forks have no OIDC token, so Depot falls back to its open-source pull request flow (https://depot.dev/blog/github-actions-oss-fork-builds): the full matrix builds on ephemeral builders without the project cache, nothing is saved, and each build job loads its amd64 image onto the runner and runs `scripts/test-image.sh` there instead, so the build check still means a tested image. A maintainer publishes a fork's images to `serversideup/php-dev`, and gets the arm64 tests, by running the "Docker Publish (PR Images)" workflow manually with the PR number. @@ -115,9 +118,10 @@ 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`, `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. +- Every PR run starts with `service_lint.yml` (script tests, actionlint and zizmor for workflows with zizmor's project decisions recorded in `.github/zizmor.yml`, 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`, `fpm.sh`, `security.sh`, `entrypoint.sh`, `laravel-automations.sh`, `permissions.sh`, `lifecycle.sh`, `web-server.sh`, `ssl.sh`, `trusted-proxy.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`, `fpm`), 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. +- `scheduled-task_scorecard.yml` runs OpenSSF Scorecard on pushes to `main`, weekly, and on branch protection changes, and publishes the score behind the README badge. Publishing only works while the workflow follows Scorecard's rules: no workflow-level `env`, `defaults`, or write permissions, and only Scorecard's approved actions in the job. - `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 diff --git a/README.md b/README.md index 6e243143..5638fe49 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

Build Status + OpenSSF Scorecard License Support us
diff --git a/SECURITY.md b/SECURITY.md index 4a011fee..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) +- 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 @@ -34,7 +34,13 @@ Vulnerabilities in software we package but do not maintain belong with the upstr | Composer | [composer/composer on GitHub](https://github.com/composer/composer/security) | | Debian / Alpine base packages | The respective distribution security teams | -If an upstream CVE is already publicly disclosed, you don't need to file with us — we pick up upstream patches on our **weekly rebuilds (Tuesday 08:00 UTC)** for floating tags. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds. +If an upstream CVE is already publicly disclosed, you don't need to file with us. Floating tags pick up upstream fixes on their own: a new PHP patch publishes within about an hour of php.net listing it, and operating system packages update on the **weekly rebuild (Tuesday 08:00 UTC)**. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds. + +## Verifying images and public scan results + +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 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/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..5288947b --- /dev/null +++ b/docs/content/docs/5.guide/7.verifying-images.md @@ -0,0 +1,76 @@ +--- +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" +} +``` + +## 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/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.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/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"],