From b577ec51514d99c5888602688509d8e102227c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:04:20 +0000 Subject: [PATCH 1/2] chore: stop release tag from reaching shell, sed, and manifests unvalidated Sync Package Manifests and Post-release Verify pasted the release tag (dispatch input or release name) into run: scripts via ${{ }}, so a tag such as v1.2.3$(cmd) executed in a job holding CI_PUSH_TOKEN and WINGET_GH_PAT. Bind the tag through env:, reject anything that is not vMAJOR.MINOR.PATCH[-PRERELEASE], and use "$TAG" in gh release download. Stop persisting CI_PUSH_TOKEN in .git/config: check out with persist-credentials: false and hand the PAT only to the push command. render.sh now validates the version, base URL and checksums before splicing them into the |-delimited sed program and the Ruby/JSON/YAML manifests, and looks checksums up by exact filename. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LE9LfkthhkrwR8PFLpAvWP --- .github/workflows/post-release-verify.yml | 72 ++++++++++++++++++----- .github/workflows/sync-manifests.yml | 38 +++++++++--- packaging/render.sh | 15 ++++- 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/.github/workflows/post-release-verify.yml b/.github/workflows/post-release-verify.yml index 94a575f..fc5da61 100644 --- a/.github/workflows/post-release-verify.yml +++ b/.github/workflows/post-release-verify.yml @@ -24,20 +24,30 @@ jobs: - name: Resolve release tag id: release + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - tag="${{ inputs.tag }}" + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" else - tag="${{ github.event.release.tag_name }}" + tag="$RELEASE_TAG" + fi + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." + exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Download checksums env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} run: | mkdir -p dist/verify - gh release download "${{ steps.release.outputs.tag }}" \ + gh release download "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern checksums.txt \ --dir dist/verify @@ -73,20 +83,30 @@ jobs: steps: - name: Resolve release tag id: release + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - tag="${{ inputs.tag }}" + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" else - tag="${{ github.event.release.tag_name }}" + tag="$RELEASE_TAG" + fi + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." + exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Download amd64 deb env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} run: | mkdir -p dist/verify - gh release download "${{ steps.release.outputs.tag }}" \ + gh release download "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern "*_amd64.deb" \ --dir dist/verify @@ -115,11 +135,20 @@ jobs: steps: - name: Resolve release tag id: release + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - tag="${{ inputs.tag }}" + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" else - tag="${{ github.event.release.tag_name }}" + tag="$RELEASE_TAG" + fi + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." + exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" @@ -129,9 +158,10 @@ jobs: - name: Download amd64 rpm env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} run: | mkdir -p dist/verify - gh release download "${{ steps.release.outputs.tag }}" \ + gh release download "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern "*_amd64.rpm" \ --dir dist/verify @@ -188,11 +218,20 @@ jobs: - name: Resolve release tag id: release shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - tag="${{ inputs.tag }}" + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" else - tag="${{ github.event.release.tag_name }}" + tag="$RELEASE_TAG" + fi + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." + exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" @@ -200,9 +239,10 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} run: | mkdir -p dist/verify - gh release download "${{ steps.release.outputs.tag }}" \ + gh release download "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern "*_windows_amd64.zip" \ --dir dist/verify diff --git a/.github/workflows/sync-manifests.yml b/.github/workflows/sync-manifests.yml index e638fc7..8678a6a 100644 --- a/.github/workflows/sync-manifests.yml +++ b/.github/workflows/sync-manifests.yml @@ -27,26 +27,40 @@ jobs: uses: actions/checkout@v4 with: ref: main - # Use a PAT so the push step can bypass the branch protection rule - # that blocks GITHUB_TOKEN from pushing directly to main. - token: ${{ secrets.CI_PUSH_TOKEN }} - + # Don't write a token into .git/config: the branch-protection-bypass + # PAT (CI_PUSH_TOKEN) is handed only to the push command below. + persist-credentials: false + + # The tag is caller-chosen text (dispatch input or release name): bind it + # through env, never as an expression inside run:, and reject anything + # that isn't a plain semver tag before it reaches downloads, sed, or the + # manifests. - name: Resolve release tag id: release + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - tag="${{ inputs.tag }}" + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" else - tag="${{ github.event.release.tag_name }}" + tag="$RELEASE_TAG" + fi + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." + exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Download checksums env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} run: | mkdir -p dist/render - gh release download "${{ steps.release.outputs.tag }}" \ + gh release download "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --pattern checksums.txt \ --dir dist/render @@ -84,6 +98,9 @@ jobs: - name: Commit Homebrew formula and Scoop manifest env: TAG: ${{ steps.release.outputs.tag }} + # PAT that can bypass the branch protection rule blocking + # GITHUB_TOKEN from pushing directly to main. Scoped to this step. + CI_PUSH_TOKEN: ${{ secrets.CI_PUSH_TOKEN }} run: | set -euo pipefail @@ -100,7 +117,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "Update Homebrew formula and Scoop manifest for ${TAG}" - git push + auth="$(printf 'x-access-token:%s' "$CI_PUSH_TOKEN" | base64 -w0)" + echo "::add-mask::${auth}" + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}" \ + push origin HEAD:main - name: Submit WinGet manifests to winget-pkgs env: diff --git a/packaging/render.sh b/packaging/render.sh index 3a47120..bfb468d 100755 --- a/packaging/render.sh +++ b/packaging/render.sh @@ -14,11 +14,22 @@ base_url="$3" template="$4" output="$5" +# The values below are spliced into a sed program and into Ruby/JSON/YAML +# manifests, so restrict them to characters that are inert in all of those. +if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "ERROR: invalid version: expected MAJOR.MINOR.PATCH[-PRERELEASE]" >&2 + exit 1 +fi +if ! [[ "$base_url" =~ ^https://[A-Za-z0-9._~/-]+$ ]]; then + echo "ERROR: invalid base URL: expected https:// with only [A-Za-z0-9._~/-]" >&2 + exit 1 +fi + sha_for() { local file="$1" local hash - hash="$(grep " ${file}$" "$checksum_file" | awk '{print $1}')" - if [ -z "$hash" ]; then + hash="$(awk -v f="$file" '$2 == f {print $1}' "$checksum_file")" + if ! [[ "$hash" =~ ^[0-9a-f]{64}$ ]]; then echo "ERROR: checksum not found for ${file} in ${checksum_file}" >&2 exit 1 fi From 7671ec33bc3def68675c8a92f4b5c4667d00ea1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:06:13 +0000 Subject: [PATCH 2/2] chore: keep release PATs off argv and out of git config, tighten tag regex Pass the CI_PUSH_TOKEN and WINGET_GH_PAT auth headers to git through GIT_CONFIG_* env for only the commands that need them, instead of on the command line or embedded in the winget-pkgs clone URL (which persisted the PAT in /tmp/winget-pkgs/.git/config). Reject empty or dot-only prerelease segments such as v1.2.3-.. that would only fail later as an invalid WinGet branch name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LE9LfkthhkrwR8PFLpAvWP --- .github/workflows/post-release-verify.yml | 8 +++---- .github/workflows/sync-manifests.yml | 26 +++++++++++++++++------ packaging/render.sh | 2 +- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/post-release-verify.yml b/.github/workflows/post-release-verify.yml index fc5da61..9905dec 100644 --- a/.github/workflows/post-release-verify.yml +++ b/.github/workflows/post-release-verify.yml @@ -35,7 +35,7 @@ jobs: else tag="$RELEASE_TAG" fi - if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." exit 1 fi @@ -94,7 +94,7 @@ jobs: else tag="$RELEASE_TAG" fi - if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." exit 1 fi @@ -146,7 +146,7 @@ jobs: else tag="$RELEASE_TAG" fi - if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." exit 1 fi @@ -229,7 +229,7 @@ jobs: else tag="$RELEASE_TAG" fi - if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." exit 1 fi diff --git a/.github/workflows/sync-manifests.yml b/.github/workflows/sync-manifests.yml index 8678a6a..dde78d1 100644 --- a/.github/workflows/sync-manifests.yml +++ b/.github/workflows/sync-manifests.yml @@ -48,7 +48,7 @@ jobs: else tag="$RELEASE_TAG" fi - if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then echo "::error::Invalid release tag; expected vMAJOR.MINOR.PATCH[-PRERELEASE]." exit 1 fi @@ -119,8 +119,11 @@ jobs: git commit -m "Update Homebrew formula and Scoop manifest for ${TAG}" auth="$(printf 'x-access-token:%s' "$CI_PUSH_TOKEN" | base64 -w0)" echo "::add-mask::${auth}" - git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}" \ - push origin HEAD:main + # Pass the auth header via env, not argv, so it isn't on the command line. + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ + git push origin HEAD:main - name: Submit WinGet manifests to winget-pkgs env: @@ -153,8 +156,19 @@ jobs: exit 1 fi - git clone --depth 1 \ - "https://x-access-token:${WINGET_GH_PAT}@github.com/${WINGET_FORK}.git" \ + # Supply the PAT as a per-command auth header so it is neither on the + # command line nor persisted in /tmp/winget-pkgs/.git/config. + winget_auth="$(printf 'x-access-token:%s' "$WINGET_GH_PAT" | base64 -w0)" + echo "::add-mask::${winget_auth}" + with_winget_auth() { + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="http.https://github.com/${WINGET_FORK}.git.extraheader" \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${winget_auth}" \ + "$@" + } + + with_winget_auth git clone --depth 1 \ + "https://github.com/${WINGET_FORK}.git" \ /tmp/winget-pkgs cd /tmp/winget-pkgs @@ -171,7 +185,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "New version: DoiT.dci version ${version}" - git push -u origin "DoiT.dci-${version}" --force + with_winget_auth git push -u origin "DoiT.dci-${version}" --force # Create PR to microsoft/winget-pkgs fork_owner="${WINGET_FORK%%/*}" diff --git a/packaging/render.sh b/packaging/render.sh index bfb468d..a6bcc2f 100755 --- a/packaging/render.sh +++ b/packaging/render.sh @@ -16,7 +16,7 @@ output="$5" # The values below are spliced into a sed program and into Ruby/JSON/YAML # manifests, so restrict them to characters that are inert in all of those. -if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then +if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then echo "ERROR: invalid version: expected MAJOR.MINOR.PATCH[-PRERELEASE]" >&2 exit 1 fi