From 42993f42bdf4fc9ff9f30156d9fda4811f888f0d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 21 Oct 2026 09:47:44 -0700 Subject: [PATCH] ci(security): no ${{ }} in run:, validate resolved versions, LiteSpeed index over HTTPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review of #30: step outputs derived from the LiteSpeed apt index (fetched over plain HTTP) were spliced into shell via ${{ }} in jobs that hold registry push secrets — a MITM'd "version" would be shell injection. - every run: block in build-push.yaml and lsphp-source-emergency.yaml now receives matrix values, step outputs, inputs and run_id via env: only (incl. the pre-existing php.net / LiteSpeed resolve steps from #24/#29) - resolved versions are regex-checked before GITHUB_OUTPUT: PHP ^[0-9]+\.[0-9]+\.[0-9]+$, deb ^[0-9][0-9A-Za-z.+~:-]*$ - LiteSpeed Packages index fetched over HTTPS (--proto =https); measured rpms.litespeedtech.com serves it (HTTP/2 200) - lsphp-src-build.sh / lsphp-src-overlay.sh validate PHPVER too Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-push.yaml | 78 +++++++++++++++----- .gitea/workflows/lsphp-source-emergency.yaml | 34 ++++++--- scripts/lsphp-src-build.sh | 1 + scripts/lsphp-src-overlay.sh | 1 + 4 files changed, 82 insertions(+), 32 deletions(-) diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 5953a5a..e6d8a20 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -97,19 +97,26 @@ jobs: # the package-upgrade layers consume CACHE_BUST and can never be reused. - name: Cache-bust value id: bust - run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + env: + RUN_ID: ${{ gitea.run_id }} + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${RUN_ID}" >> "$GITHUB_OUTPUT" # The minimum PHP version this build must ship: php.net's current release for # the branch (over HTTPS). The Dockerfile fails if Remi gave us anything older, # so lagging mirrors or replayed repo metadata can't ship a vulnerable PHP. - name: Resolve current PHP release from php.net id: phpmin + # Values reach the script via env, never via ${{ }} inside run: (a + # crafted string would otherwise be shell); outputs are regex-checked. + env: + PHPVER: ${{ matrix.phpver }} run: | set -euo pipefail - v="${{ matrix.phpver }}"; branch="${v:0:1}.${v:1}" + v="$PHPVER"; branch="${v:0:1}.${v:1}" json=$(curl -fsS --retry 5 --max-time 60 "https://www.php.net/releases/index.php?json&max=1&version=${branch}") min=$(printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(next(iter(d)) if isinstance(d,dict) and d else "")') if [ -z "$min" ]; then echo "::error::no php.net release found for ${branch}"; exit 1; fi + [[ "$min" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::php.net returned a malformed version for ${branch}"; exit 1; } echo "php.net current ${branch}: ${min}" echo "min=${min}" >> "$GITHUB_OUTPUT" @@ -157,19 +164,26 @@ jobs: # the package-upgrade layers consume CACHE_BUST and can never be reused. - name: Cache-bust value id: bust - run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + env: + RUN_ID: ${{ gitea.run_id }} + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${RUN_ID}" >> "$GITHUB_OUTPUT" # The minimum PHP version this build must ship: php.net's current release for # the branch (over HTTPS). The Dockerfile fails if Remi gave us anything older, # so lagging mirrors or replayed repo metadata can't ship a vulnerable PHP. - name: Resolve current PHP release from php.net id: phpmin + # Values reach the script via env, never via ${{ }} inside run: (a + # crafted string would otherwise be shell); outputs are regex-checked. + env: + PHPVER: ${{ matrix.phpver }} run: | set -euo pipefail - v="${{ matrix.phpver }}"; branch="${v:0:1}.${v:1}" + v="$PHPVER"; branch="${v:0:1}.${v:1}" json=$(curl -fsS --retry 5 --max-time 60 "https://www.php.net/releases/index.php?json&max=1&version=${branch}") min=$(printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(next(iter(d)) if isinstance(d,dict) and d else "")') if [ -z "$min" ]; then echo "::error::no php.net release found for ${branch}"; exit 1; fi + [[ "$min" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::php.net returned a malformed version for ${branch}"; exit 1; } echo "php.net current ${branch}: ${min}" echo "min=${min}" >> "$GITHUB_OUTPUT" @@ -222,7 +236,9 @@ jobs: # the package-upgrade layers consume CACHE_BUST and can never be reused. - name: Cache-bust value id: bust - run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + env: + RUN_ID: ${{ gitea.run_id }} + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${RUN_ID}" >> "$GITHUB_OUTPUT" # Ask the LiteSpeed apt repo (the same one the base image's apt uses) for the # CURRENT lsphp version. The Dockerfile asserts the installed package equals @@ -230,14 +246,21 @@ jobs: # it into the image labels org.whp.php-version / org.whp.lsphp-package-version. - name: Resolve current lsphp${{ matrix.phpver }} from the LiteSpeed repo id: lsphp + # HTTPS (rpms.litespeedtech.com serves it; measured 2026-10-21), values via + # env, and both outputs regex-checked before they reach GITHUB_OUTPUT. + env: + PHPVER: ${{ matrix.phpver }} run: | set -euo pipefail - idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) - pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) - if [ -z "$pkg" ]; then echo "::error::lsphp${{ matrix.phpver }} not found in the LiteSpeed repo index"; exit 1; fi - echo "lsphp${{ matrix.phpver }} in repo: $pkg (PHP ${pkg%%-*})" + idx=$(curl -fsS --proto '=https' --retry 3 --max-time 60 https://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) + pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${PHPVER}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) + if [ -z "$pkg" ]; then echo "::error::lsphp${PHPVER} not found in the LiteSpeed repo index"; exit 1; fi + php="${pkg%%-*}" + [[ "$pkg" =~ ^[0-9][0-9A-Za-z.+~:-]*$ ]] || { echo "::error::malformed lsphp${PHPVER} package version in the repo index"; exit 1; } + [[ "$php" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::malformed PHP version '${php}' derived from lsphp${PHPVER} ${pkg}"; exit 1; } + echo "lsphp${PHPVER} in repo: $pkg (PHP ${php})" echo "pkg=$pkg" >> "$GITHUB_OUTPUT" - echo "php=${pkg%%-*}" >> "$GITHUB_OUTPUT" + echo "php=${php}" >> "$GITHUB_OUTPUT" - name: Login to Gitea uses: docker/login-action@v3 @@ -252,12 +275,15 @@ jobs: # the job goes green again by itself once the LiteSpeed repo catches up. # Fail-open if the tag cannot be inspected (first build, registry blip). - name: Do not roll back a newer php.net emergency image + env: + PHPVER: ${{ matrix.phpver }} + NEW_PHP: ${{ steps.lsphp.outputs.php }} run: | set -euo pipefail - ref=repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:php${{ matrix.phpver }} + ref="repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:php${PHPVER}" src=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.lsphp-source" }}' 2>/dev/null || true) cur=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.php-version" }}' 2>/dev/null || true) - new='${{ steps.lsphp.outputs.php }}' + new="$NEW_PHP" echo "published $ref: source=${src:-?} php=${cur:-?}; this build: packaged $new" if [ "$src" = "php.net" ] && [ "$cur" != "$new" ] && [ "$(printf '%s\n%s\n' "$cur" "$new" | sort -V | tail -n1)" = "$cur" ]; then echo "::error::$ref is a php.net emergency build of PHP $cur; LiteSpeed still ships $new. Not overwriting it with an older PHP. Re-run lsphp-source-emergency.yaml for a newer fix, or wait for LiteSpeed." @@ -313,7 +339,9 @@ jobs: # the package-upgrade layers consume CACHE_BUST and can never be reused. - name: Cache-bust value id: bust - run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + env: + RUN_ID: ${{ gitea.run_id }} + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${RUN_ID}" >> "$GITHUB_OUTPUT" # Ask the LiteSpeed apt repo (the same one the base image's apt uses) for the # CURRENT lsphp version. The Dockerfile asserts the installed package equals @@ -321,14 +349,21 @@ jobs: # it into the image labels org.whp.php-version / org.whp.lsphp-package-version. - name: Resolve current lsphp${{ matrix.phpver }} from the LiteSpeed repo id: lsphp + # HTTPS (rpms.litespeedtech.com serves it; measured 2026-10-21), values via + # env, and both outputs regex-checked before they reach GITHUB_OUTPUT. + env: + PHPVER: ${{ matrix.phpver }} run: | set -euo pipefail - idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) - pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) - if [ -z "$pkg" ]; then echo "::error::lsphp${{ matrix.phpver }} not found in the LiteSpeed repo index"; exit 1; fi - echo "lsphp${{ matrix.phpver }} in repo: $pkg (PHP ${pkg%%-*})" + idx=$(curl -fsS --proto '=https' --retry 3 --max-time 60 https://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) + pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${PHPVER}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) + if [ -z "$pkg" ]; then echo "::error::lsphp${PHPVER} not found in the LiteSpeed repo index"; exit 1; fi + php="${pkg%%-*}" + [[ "$pkg" =~ ^[0-9][0-9A-Za-z.+~:-]*$ ]] || { echo "::error::malformed lsphp${PHPVER} package version in the repo index"; exit 1; } + [[ "$php" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::malformed PHP version '${php}' derived from lsphp${PHPVER} ${pkg}"; exit 1; } + echo "lsphp${PHPVER} in repo: $pkg (PHP ${php})" echo "pkg=$pkg" >> "$GITHUB_OUTPUT" - echo "php=${pkg%%-*}" >> "$GITHUB_OUTPUT" + echo "php=${php}" >> "$GITHUB_OUTPUT" - name: Login to Gitea uses: docker/login-action@v3 @@ -343,12 +378,15 @@ jobs: # the job goes green again by itself once the LiteSpeed repo catches up. # Fail-open if the tag cannot be inspected (first build, registry blip). - name: Do not roll back a newer php.net emergency image + env: + PHPVER: ${{ matrix.phpver }} + NEW_PHP: ${{ steps.lsphp.outputs.php }} run: | set -euo pipefail - ref=repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:php${{ matrix.phpver }} + ref="repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:php${PHPVER}" src=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.lsphp-source" }}' 2>/dev/null || true) cur=$(docker buildx imagetools inspect "$ref" --format '{{ index .Image.Config.Labels "org.whp.php-version" }}' 2>/dev/null || true) - new='${{ steps.lsphp.outputs.php }}' + new="$NEW_PHP" echo "published $ref: source=${src:-?} php=${cur:-?}; this build: packaged $new" if [ "$src" = "php.net" ] && [ "$cur" != "$new" ] && [ "$(printf '%s\n%s\n' "$cur" "$new" | sort -V | tail -n1)" = "$cur" ]; then echo "::error::$ref is a php.net emergency build of PHP $cur; LiteSpeed still ships $new. Not overwriting it with an older PHP. Re-run lsphp-source-emergency.yaml for a newer fix, or wait for LiteSpeed." diff --git a/.gitea/workflows/lsphp-source-emergency.yaml b/.gitea/workflows/lsphp-source-emergency.yaml index 5015b80..e483156 100644 --- a/.gitea/workflows/lsphp-source-emergency.yaml +++ b/.gitea/workflows/lsphp-source-emergency.yaml @@ -49,6 +49,8 @@ jobs: IN_VERSIONS: ${{ inputs.php_versions }} IN_IMAGES: ${{ inputs.images }} IN_PROD: ${{ format('{0}', inputs.push_production_tags) }} + PHPVER: ${{ matrix.phpver }} + IMG: ${{ matrix.image }} run: | set -euo pipefail case "$IN_IMAGES" in both|lsphp|litespeed) ;; *) echo "::error::images must be both|lsphp|litespeed, got '$IN_IMAGES'"; exit 1 ;; esac @@ -56,16 +58,17 @@ jobs: v="" for pair in $IN_VERSIONS; do [[ "$pair" =~ ^(8[1-5])=([0-9]+\.[0-9]+\.[0-9]+)$ ]] || { echo "::error::bad php_versions entry '$pair' (want NN=X.Y.Z, NN in 81..85)"; exit 1; } - [ "${BASH_REMATCH[1]}" = '${{ matrix.phpver }}' ] && v="${BASH_REMATCH[2]}" + [ "${BASH_REMATCH[1]}" = "$PHPVER" ] && v="${BASH_REMATCH[2]}" done echo "prod=$IN_PROD" >> "$GITHUB_OUTPUT" - if [ -z "$v" ] || { [ "$IN_IMAGES" != both ] && [ "$IN_IMAGES" != '${{ matrix.image }}' ]; }; then - echo "not requested: php${{ matrix.phpver }} / ${{ matrix.image }}"; echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0 + if [ -z "$v" ] || { [ "$IN_IMAGES" != both ] && [ "$IN_IMAGES" != "$IMG" ]; }; then + echo "not requested: php${PHPVER} / ${IMG}"; echo "skip=1" >> "$GITHUB_OUTPUT"; exit 0 fi echo "skip=0" >> "$GITHUB_OUTPUT" echo "version=$v" >> "$GITHUB_OUTPUT" - echo "image_name=cac-${{ matrix.image }}" >> "$GITHUB_OUTPUT" - echo "dockerfile=./Dockerfile.${{ matrix.image }}" >> "$GITHUB_OUTPUT" + case "$IMG" in lsphp|litespeed) ;; *) echo "::error::unexpected matrix image '$IMG'"; exit 1 ;; esac + echo "image_name=cac-${IMG}" >> "$GITHUB_OUTPUT" + echo "dockerfile=./Dockerfile.${IMG}" >> "$GITHUB_OUTPUT" - name: Checkout if: steps.t.outputs.skip == '0' @@ -77,26 +80,33 @@ jobs: - name: Resolve php.net release + packaged lsphp if: steps.t.outputs.skip == '0' id: v + env: + V: ${{ steps.t.outputs.version }} + PHPVER: ${{ matrix.phpver }} + RUN_ID: ${{ gitea.run_id }} run: | set -euo pipefail - v='${{ steps.t.outputs.version }}' + v="$V" + [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::malformed version '$v'"; exit 1; } json=$(curl -fsS --retry 3 --max-time 60 "https://www.php.net/releases/index.php?json&version=$v") sha=$(printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); v=sys.argv[1]; print(next((s["sha256"] for s in d.get("source", []) if s.get("filename")=="php-%s.tar.xz"%v), ""))' "$v") [ -n "$sha" ] || { echo "::error::php.net has no release php-$v.tar.xz (EOL branch? typo?): $json"; exit 1; } [[ "$sha" =~ ^[0-9a-f]{64}$ ]] || { echo "::error::no sha256 for php-$v.tar.xz in the php.net releases JSON"; exit 1; } - idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) - pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) - [ -n "$pkg" ] || { echo "::error::lsphp${{ matrix.phpver }} not in the LiteSpeed repo index"; exit 1; } + idx=$(curl -fsS --proto '=https' --retry 3 --max-time 60 https://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) + pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${PHPVER}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) + [ -n "$pkg" ] || { echo "::error::lsphp${PHPVER} not in the LiteSpeed repo index"; exit 1; } + [[ "$pkg" =~ ^[0-9][0-9A-Za-z.+~:-]*$ ]] || { echo "::error::malformed lsphp${PHPVER} package version in the repo index"; exit 1; } pphp=${pkg%%-*} + [[ "$pphp" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::malformed PHP version '$pphp' from lsphp${PHPVER} $pkg"; exit 1; } if [ "$(printf '%s\n%s\n' "$pphp" "$v" | sort -V | tail -n1)" != "$v" ] || [ "$pphp" = "$v" ]; then - echo "::error::LiteSpeed already ships lsphp${{ matrix.phpver }} $pkg (PHP $pphp) >= $v — use the normal build (push to trunk / dispatch build-push.yaml)." + echo "::error::LiteSpeed already ships lsphp${PHPVER} $pkg (PHP $pphp) >= $v — use the normal build (push to trunk / dispatch build-push.yaml)." exit 1 fi - echo "php.net $v sha256=$sha ; LiteSpeed lsphp${{ matrix.phpver }}=$pkg" + echo "php.net $v sha256=$sha ; LiteSpeed lsphp${PHPVER}=$pkg" echo "sha=$sha" >> "$GITHUB_OUTPUT" echo "pkg=$pkg" >> "$GITHUB_OUTPUT" echo "pkgphp=$pphp" >> "$GITHUB_OUTPUT" - echo "bust=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + echo "bust=$(date -u +%Y%m%dT%H%M%SZ)-${RUN_ID}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx if: steps.t.outputs.skip == '0' diff --git a/scripts/lsphp-src-build.sh b/scripts/lsphp-src-build.sh index e6b158b..e5d390e 100755 --- a/scripts/lsphp-src-build.sh +++ b/scripts/lsphp-src-build.sh @@ -59,6 +59,7 @@ die() { echo "FATAL(lsphp-src-build): $*" >&2; exit 1; } log() { echo "lsphp-src-build: $*"; } PHPVER="${PHPVER:?PHPVER required}" +[[ "$PHPVER" =~ ^[0-9]{2}$ ]] || die "PHPVER='$PHPVER' is not NN." if [ -z "${PHP_SRC_VERSION:-}" ]; then [ -z "${PHP_SRC_SHA256:-}" ] || die "PHP_SRC_SHA256 given without PHP_SRC_VERSION." log "PHP_SRC_VERSION empty — packaged lsphp build, nothing to do." diff --git a/scripts/lsphp-src-overlay.sh b/scripts/lsphp-src-overlay.sh index f90bfad..ce69695 100755 --- a/scripts/lsphp-src-overlay.sh +++ b/scripts/lsphp-src-overlay.sh @@ -37,6 +37,7 @@ MODE="${1:?usage: lsphp-src-overlay.sh runtime|dev PHPVER}" PHPVER="${2:?usage: lsphp-src-overlay.sh runtime|dev PHPVER}" OUT="${3:-/lsphp-src-out}" case "$MODE" in runtime|dev) ;; *) echo "FATAL(lsphp-src-overlay): mode must be runtime|dev" >&2; exit 1 ;; esac +[[ "$PHPVER" =~ ^[0-9]{2}$ ]] || { echo "FATAL(lsphp-src-overlay): PHPVER='$PHPVER' is not NN" >&2; exit 1; } die() { echo "FATAL(lsphp-src-overlay): $*" >&2; exit 1; } log() { echo "lsphp-src-overlay[$MODE]: $*"; }