diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..09f35c9 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,35 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: # zizmor: ignore[concurrency-limits] + push: + branches: + - main + paths: + - '.github/workflows/*.yml' + - 'action.yml' + pull_request: + paths: + - '.github/workflows/*.yml' + - 'action.yml' + +permissions: {} + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + with: + advanced-security: false + annotations: true + online-audits: false + persona: 'pedantic' diff --git a/README.md b/README.md index d6cdbe7..3bb400f 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,21 @@ For example: skip_git_hooks: "true" ``` +#### packagist_url + +The `packagist_url` input parameter sets the base URL of the Private Packagist instance that dispatches this action. +Webhook callbacks in the dispatched payload must point at a URL under this prefix; any other host is refused before +the action makes the HTTP request, preventing a hostile payload from redirecting the webhook (and its credentials) +to an attacker-controlled server. + +The default is `https://packagist.com`. Override it only when running a Private Packagist Self-Hosted installation: + +```yaml +- uses: packagist/conductor-github-action + with: + packagist_url: "https://packagist.example.com" +``` + ## Copyright and License The GitHub Action is licensed under the MIT License. diff --git a/action.yml b/action.yml index 3dc5543..c36b5da 100644 --- a/action.yml +++ b/action.yml @@ -12,84 +12,121 @@ inputs: description: Skip any git hooks that get installed as part of the GitHub Action e.g. during composer install or update. default: 'false' required: false + packagist_url: + description: Base URL of the Private Packagist instance that dispatches this action. Webhook URLs in the payload must be under this prefix. Requests to any other host are refused. Override this for Self-Hosted installations. + default: 'https://packagist.com' + required: false runs: using: "composite" steps: - # Temporary workaround to make sure you can set up Conductor for - # the first time. The CI verification job runs "composer update nothing" - # which fails if your composer.lock contains any versions with - # known security issues in Composer >=2.9.0 - - name: Set security blocking environment variable - shell: "bash" - run: echo "COMPOSER_NO_SECURITY_BLOCKING=${{ github.event.client_payload.branch == 'conductor-nothing' && 1 || 0 }}" >> $GITHUB_ENV - - name: Set Conductor version - shell: "bash" - run: echo "CONDUCTOR_ACTION_VERSION=1.5.3" >> $GITHUB_ENV - - run: | + # Set local environment variables using jq instead of passing the values via env: to not leak secrets before masking them + - name: Mask Composer authentication token + shell: bash + run: | CONDUCTOR_TOKEN=$(jq -r '.client_payload.composerAuthentication.token' $GITHUB_EVENT_PATH) echo "::add-mask::$CONDUCTOR_TOKEN" if: ${{ github.event.client_payload.composerAuthentication.type != 'none' }} - shell: "bash" + + - name: Mask webhook authentication token + shell: bash + run: | + WEBHOOK_AUTHENTICATION_PASSWORD=$(jq -r '.client_payload.webhook.authentication.password' $GITHUB_EVENT_PATH) + echo "::add-mask::$WEBHOOK_AUTHENTICATION_PASSWORD" + + # This is the version that needs to be increased for each release of the GitHub Action + - name: Set Conductor version + shell: bash + run: echo "CONDUCTOR_ACTION_VERSION=1.5.3" >> $GITHUB_ENV + + - name: Validate Conductor branch name + shell: bash + run: '"${GITHUB_ACTION_PATH}/bin/branch_name_check.sh" "${BRANCH}"' + env: + BRANCH: ${{ github.event.client_payload.branch }} - name: "Validate GitHub action version" - shell: "bash" - run: "${GITHUB_ACTION_PATH}/bin/ci_version_check.sh ${{ github.event.client_payload.requirements.minimumCiActionVersion }} $CONDUCTOR_ACTION_VERSION" + shell: bash + run: "${GITHUB_ACTION_PATH}/bin/ci_version_check.sh ${MINIMUM_CI_ACTION_VERSION} $CONDUCTOR_ACTION_VERSION" + env: + MINIMUM_CI_ACTION_VERSION: ${{ github.event.client_payload.requirements.minimumCiActionVersion }} - name: "Validate PHP version" - shell: "bash" - run: "${GITHUB_ACTION_PATH}/bin/php_version_check.sh ${{ github.event.client_payload.requirements.minimumPhpVersion }}" + shell: bash + run: "${GITHUB_ACTION_PATH}/bin/php_version_check.sh ${MINIMUM_PHP_VERSION}" + env: + MINIMUM_PHP_VERSION: ${{ github.event.client_payload.requirements.minimumPhpVersion }} - name: "Validate Composer version" - shell: "bash" - run: "${GITHUB_ACTION_PATH}/bin/composer_version_check.sh ${{ github.event.client_payload.requirements.minimumComposerVersion }}" + shell: bash + run: "${GITHUB_ACTION_PATH}/bin/composer_version_check.sh ${MINIMUM_COMPOSER_VERSION}" + env: + MINIMUM_COMPOSER_VERSION: ${{ github.event.client_payload.requirements.minimumComposerVersion }} + + # Temporary workaround to make sure you can set up Conductor for + # the first time. The CI verification job runs "composer update nothing" + # which fails if your composer.lock contains any versions with + # known security issues in Composer >=2.9.0 + - name: Set security blocking environment variable + shell: bash + run: | + if [[ "${BRANCH}" == "conductor-nothing" ]]; then + echo "COMPOSER_NO_SECURITY_BLOCKING=1" >> "$GITHUB_ENV" + else + echo "COMPOSER_NO_SECURITY_BLOCKING=0" >> "$GITHUB_ENV" + fi + env: + BRANCH: ${{ github.event.client_payload.branch }} - name: Store base commit info + shell: bash id: base_commit_info run: | git log -1 --format="HASH=%H" >> $GITHUB_OUTPUT git log -1 --format="AUTHOR=%an" >> $GITHUB_OUTPUT git log -1 --format="MESSAGE=%s" >> $GITHUB_OUTPUT - shell: bash - - - name: Configure Composer authentication - shell: "bash" - if: ${{ github.event.client_payload.composerAuthentication.type == 'environment' }} - run: echo 'COMPOSER_AUTH=${{ github.event.client_payload.composerAuthentication.environment }}' >> "$GITHUB_ENV" - name: Install dependencies uses: ramsey/composer-install@a35c6ebd3d08125aaf8852dff361e686a1a67947 # 3.2.0 + env: + COMPOSER_AUTH: ${{ github.event.client_payload.composerAuthentication.type == 'environment' && github.event.client_payload.composerAuthentication.environment || env.COMPOSER_AUTH }} with: working-directory: "${{ github.event.client_payload.workingDirectory }}" composer-options: "${{ github.event.client_payload.settings.debug == true && '-vvv' || '' }}" - name: Modify requirements in the composer.json - run: "${{ github.event.client_payload.settings.debug == true && github.event.client_payload.requireCommand.debug || github.event.client_payload.requireCommand.plain }}" if: ${{ github.event.client_payload.requireCommand }} shell: bash + run: '"${GITHUB_ACTION_PATH}/bin/run_composer_command.sh" require' working-directory: "${{ github.event.client_payload.workingDirectory }}" + env: + COMPOSER_COMMAND_STRING: ${{ github.event.client_payload.settings.debug == true && github.event.client_payload.requireCommand.debug || github.event.client_payload.requireCommand.plain }} + COMPOSER_AUTH: ${{ github.event.client_payload.composerAuthentication.type == 'environment' && github.event.client_payload.composerAuthentication.environment || env.COMPOSER_AUTH }} - name: Composer update - run: "${{ github.event.client_payload.settings.debug == true && github.event.client_payload.updateCommand.debug || github.event.client_payload.updateCommand.plain }}" shell: bash + run: '"${GITHUB_ACTION_PATH}/bin/run_composer_command.sh" update' working-directory: "${{ github.event.client_payload.workingDirectory }}" + env: + COMPOSER_COMMAND_STRING: ${{ github.event.client_payload.settings.debug == true && github.event.client_payload.updateCommand.debug || github.event.client_payload.updateCommand.plain }} + COMPOSER_AUTH: ${{ github.event.client_payload.composerAuthentication.type == 'environment' && github.event.client_payload.composerAuthentication.environment || env.COMPOSER_AUTH }} - name: Uninstall git hooks + shell: bash if: ${{ inputs.skip_git_hooks != 'false' }} run: "rm -rf .git/hooks" - shell: "bash" - name: Create branch - run: git checkout -b $BRANCH shell: bash + run: git checkout -b $BRANCH env: BRANCH: ${{ github.event.client_payload.branch }} - name: Add files + shell: bash run: | read -r -a PATTERN_EXPANDED <<< "$FILE_PATTERN"; git add ${FILE_PATTERN:+"${PATTERN_EXPANDED[@]}"}; - shell: bash env: FILE_PATTERN: ${{ inputs.file_pattern }} @@ -100,37 +137,29 @@ runs: skip-empty: true - name: Store number of changed files + shell: bash id: number_of_changed_files run: echo "COUNT=$(git --no-pager diff --name-only $GITHUB_SHA | wc -l | tr -d ' ')" >> $GITHUB_OUTPUT - shell: bash - name: Store Conductor commit info + shell: bash id: conductor_commit_info run: | git log -1 --format="HASH=%H" >> $GITHUB_OUTPUT git log -1 --format="AUTHOR=%an" >> $GITHUB_OUTPUT git log -1 --format="MESSAGE=%s" >> $GITHUB_OUTPUT - shell: bash - name: Push branch - run: git push origin $BRANCH --force shell: bash + run: git push origin $BRANCH --force if: ${{ steps.number_of_changed_files.outputs.COUNT != 0 }} env: BRANCH: ${{ github.event.client_payload.branch }} - name: Call webhook from Private Packagist to create the pull request shell: bash - env: - RUN_ID: ${{ github.run_id }}; - CHANGED_FILES: ${{ steps.number_of_changed_files.outputs.COUNT }} - BASE_COMMIT_HASH: ${{ steps.base_commit_info.outputs.HASH }} - BASE_COMMIT_AUTHOR: ${{ steps.base_commit_info.outputs.AUTHOR }} - BASE_COMMIT_MESSAGE: ${{ steps.base_commit_info.outputs.MESSAGE }} - CONDUCTOR_COMMIT_HASH: ${{ steps.conductor_commit_info.outputs.HASH }} - CONDUCTOR_COMMIT_AUTHOR: ${{ steps.conductor_commit_info.outputs.AUTHOR }} - CONDUCTOR_COMMIT_MESSAGE: ${{ steps.conductor_commit_info.outputs.MESSAGE }} run: | + "${GITHUB_ACTION_PATH}/bin/webhook_url_check.sh" "${PACKAGIST_URL}" "${WEBHOOK_EXECUTEDURL}" jq -n '{ "runId": env.RUN_ID, "numberOfChangedFiles": env.CHANGED_FILES, @@ -150,20 +179,29 @@ runs: "ciScriptVersion": env.CONDUCTOR_ACTION_VERSION } }' | curl -fsSL -X POST \ - -u "${{ github.event.client_payload.webhook.authentication.username }}:${{ github.event.client_payload.webhook.authentication.password }}" \ + -u "${WEBHOOK_AUTHENTICATION_USERNAME}:${WEBHOOK_AUTHENTICATION_PASSWORD}" \ --header "Content-Type: application/json" \ --data @- \ - "${{ github.event.client_payload.webhook.executedUrl }}" - - - name: Call webhook from Private Packagist to notify about build failure - shell: bash + "${WEBHOOK_EXECUTEDURL}" env: RUN_ID: ${{ github.run_id }} + CHANGED_FILES: ${{ steps.number_of_changed_files.outputs.COUNT }} BASE_COMMIT_HASH: ${{ steps.base_commit_info.outputs.HASH }} BASE_COMMIT_AUTHOR: ${{ steps.base_commit_info.outputs.AUTHOR }} BASE_COMMIT_MESSAGE: ${{ steps.base_commit_info.outputs.MESSAGE }} + CONDUCTOR_COMMIT_HASH: ${{ steps.conductor_commit_info.outputs.HASH }} + CONDUCTOR_COMMIT_AUTHOR: ${{ steps.conductor_commit_info.outputs.AUTHOR }} + CONDUCTOR_COMMIT_MESSAGE: ${{ steps.conductor_commit_info.outputs.MESSAGE }} + WEBHOOK_AUTHENTICATION_USERNAME: ${{ github.event.client_payload.webhook.authentication.username }} + WEBHOOK_AUTHENTICATION_PASSWORD: ${{ github.event.client_payload.webhook.authentication.password }} + WEBHOOK_EXECUTEDURL: ${{ github.event.client_payload.webhook.executedUrl }} + PACKAGIST_URL: ${{ inputs.packagist_url }} + + - name: Call webhook from Private Packagist to notify about build failure + shell: bash if: ${{ failure() }} run: | + "${GITHUB_ACTION_PATH}/bin/webhook_url_check.sh" "${PACKAGIST_URL}" "${WEBHOOK_ERRORURL}" jq -n '{ "runId": env.RUN_ID, "gitInfo": { @@ -177,7 +215,16 @@ runs: "ciScriptVersion": env.CONDUCTOR_ACTION_VERSION } }' | curl -fsSL -X POST \ - -u "${{ github.event.client_payload.webhook.authentication.username }}:${{ github.event.client_payload.webhook.authentication.password }}" \ + -u "${WEBHOOK_AUTHENTICATION_USERNAME}:${WEBHOOK_AUTHENTICATION_PASSWORD}" \ --header "Content-Type: application/json" \ --data @- \ - "${{ github.event.client_payload.webhook.errorUrl }}" + "${WEBHOOK_ERRORURL}" + env: + RUN_ID: ${{ github.run_id }} + BASE_COMMIT_HASH: ${{ steps.base_commit_info.outputs.HASH }} + BASE_COMMIT_AUTHOR: ${{ steps.base_commit_info.outputs.AUTHOR }} + BASE_COMMIT_MESSAGE: ${{ steps.base_commit_info.outputs.MESSAGE }} + WEBHOOK_AUTHENTICATION_USERNAME: ${{ github.event.client_payload.webhook.authentication.username }} + WEBHOOK_AUTHENTICATION_PASSWORD: ${{ github.event.client_payload.webhook.authentication.password }} + WEBHOOK_ERRORURL: ${{ github.event.client_payload.webhook.errorUrl }} + PACKAGIST_URL: ${{ inputs.packagist_url }} diff --git a/bin/branch_name_check.sh b/bin/branch_name_check.sh new file mode 100755 index 0000000..42ec794 --- /dev/null +++ b/bin/branch_name_check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +BRANCH="${1:?branch name required}" + +# Require every Conductor-managed branch to start with the literal prefix +# "conductor" and contain only characters that are safe inside a git refspec. +BRANCH_RE='^conductor[A-Za-z0-9._/-]*$' + +if [[ ! "${BRANCH}" =~ ${BRANCH_RE} ]]; then + echo "::error ::branch '${BRANCH}' is not allowed; must start with 'conductor' and contain only [A-Za-z0-9._/-]" + exit 1 +fi diff --git a/bin/run_composer_command.sh b/bin/run_composer_command.sh new file mode 100755 index 0000000..baaa049 --- /dev/null +++ b/bin/run_composer_command.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_SUBCOMMAND="${1:?expected subcommand required}" +: "${COMPOSER_COMMAND_STRING:?COMPOSER_COMMAND_STRING not set}" + +# `read -ra` splits on $IFS only; it does not expand $vars, run command +# substitutions, honour quoting, or perform globbing. Every shell metacharacter +# in the payload therefore stays as a literal byte inside its token. +read -ra TOKENS <<< "${COMPOSER_COMMAND_STRING}" + +if [[ "${#TOKENS[@]}" -lt 2 ]]; then + echo "::error ::composer command must contain at least a binary and a subcommand" + exit 1 +fi + +if [[ "${TOKENS[0]}" != "composer" ]]; then + echo "::error ::composer command must start with 'composer', got '${TOKENS[0]}'" + exit 1 +fi + +if [[ "${TOKENS[1]}" != "${EXPECTED_SUBCOMMAND}" ]]; then + echo "::error ::composer subcommand must be '${EXPECTED_SUBCOMMAND}', got '${TOKENS[1]}'" + exit 1 +fi + +# Reject tokens containing characters that have no business +# appearing in a Composer package name, version constraint, or flag. +SAFE_TOKEN_RE='^[A-Za-z0-9._:/@^+|=~*,<>!-]+$' +for token in "${TOKENS[@]}"; do + if [[ ! "${token}" =~ ${SAFE_TOKEN_RE} ]]; then + echo "::error ::composer command token '${token}' contains disallowed characters" + exit 1 + fi +done + +set -x +# Argv-form execution: bash passes each array element as one argv entry with +# no further parsing, so metacharacters inside a token reach Composer as +# literal string data rather than as shell syntax. +exec composer "${TOKENS[@]:1}" diff --git a/bin/webhook_url_check.sh b/bin/webhook_url_check.sh new file mode 100755 index 0000000..97afe6a --- /dev/null +++ b/bin/webhook_url_check.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +TRUSTED_BASE="${1:?trusted base URL required}" +URL="${2:?webhook URL required}" + +# Strip a single trailing slash from the base so the prefix check below can +# always append "/". Requiring the URL to start with "/" prevents a host +# like "packagist.com.evil.example" from sneaking past "packagist.com". +TRUSTED_BASE="${TRUSTED_BASE%/}" + +case "${TRUSTED_BASE}" in + https://*) ;; + *) echo "::error ::packagist_url must use https://, got '${TRUSTED_BASE}'"; exit 1 ;; +esac + +case "${URL}" in + "${TRUSTED_BASE}/"*) ;; + *) echo "::error ::webhook URL '${URL}' is not under the trusted base '${TRUSTED_BASE}/'"; exit 1 ;; +esac + +# Restrict the path portion after the trusted base to alphanumerics, dashes, +# and forward slashes. This blocks query strings, fragments, percent-encoding, +# and any other characters that have no business appearing in a Conductor +# webhook callback path. +SUFFIX="${URL#"${TRUSTED_BASE}/"}" +case "${SUFFIX}" in + *[!A-Za-z0-9/-]*) + echo "::error ::webhook URL path '${SUFFIX}' must contain only alphanumerics, '-' and '/'" + exit 1 + ;; +esac