From 6877abedebfc1c7d95f1f6ebe68945c62be528ab Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 13 Jul 2026 13:14:28 +0100 Subject: [PATCH] =?UTF-8?q?fix(ci):=20harden=20promote=20workflow=20?= =?UTF-8?q?=E2=80=94=20env-isolated=20untrusted=20input,=20main-ancestry?= =?UTF-8?q?=20check,=20concurrency=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the AI review findings on PR #33: - severe: the workflow_dispatch sha input was interpolated directly into the run script (shell injection with REGISTRY_TOKEN + prod webhook in scope). It now reaches the shell only via env, is rejected if it starts with '-', and is resolved locally with git rev-parse. - minor: the resolved sha must be a 40-hex ancestor of origin/main — non-main refs are refused explicitly instead of implicitly. - minor: a prod-promotion concurrency group serialises promotions (cancel-in-progress: false). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .gitea/workflows/promote.yml | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/promote.yml b/.gitea/workflows/promote.yml index 811e212..703c4ff 100644 --- a/.gitea/workflows/promote.yml +++ b/.gitea/workflows/promote.yml @@ -14,6 +14,11 @@ on: required: false default: "" +# Only one promotion at a time; never cancel an in-flight promotion. +concurrency: + group: prod-promotion + cancel-in-progress: false + env: REGISTRY: privaterepo.sitaru.org BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend @@ -25,18 +30,37 @@ jobs: name: Promote approved commit to Production runs-on: ubuntu-latest steps: - - name: Resolve target SHA + - name: Checkout repository (full history for ancestry check) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve and validate target SHA id: resolve + # SECURITY: the dispatch input is untrusted — it reaches the shell + # only via env (never spliced into `run:` with ${{ }}) and is only + # used as a quoted argument. The resolved value is validated as a + # 40-hex sha and required to be an ancestor of main before any + # later step interpolates it. + env: + SHA_INPUT: ${{ gitea.event.inputs.sha }} run: | - SHA_INPUT="${{ gitea.event.inputs.sha }}" + set -euo pipefail + case "$SHA_INPUT" in + -*) echo "REFUSED: SHA input may not start with '-'." >&2; exit 1 ;; + esac if [ -z "$SHA_INPUT" ]; then - SHA_INPUT="${{ gitea.sha }}" + SHA_INPUT="$(git rev-parse origin/main)" + fi + FULL_SHA=$(git rev-parse --verify --quiet "${SHA_INPUT}^{commit}") || { + echo "REFUSED: not a commit in this repository." >&2 + exit 1 + } + echo "$FULL_SHA" | grep -Eq '^[0-9a-f]{40}$' + if ! git merge-base --is-ancestor "$FULL_SHA" origin/main; then + echo "REFUSED: $FULL_SHA is not on main — only main commits are promotable." >&2 + exit 1 fi - # Normalise to the full sha via the API so short inputs work - FULL_SHA=$(curl -fsS \ - -H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \ - "https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/git/commits/${SHA_INPUT}" \ - | python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])") SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)" echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT" echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"