Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
@@ -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'
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
143 changes: 95 additions & 48 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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,
Expand All @@ -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": {
Expand All @@ -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 }}
13 changes: 13 additions & 0 deletions bin/branch_name_check.sh
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions bin/run_composer_command.sh
Original file line number Diff line number Diff line change
@@ -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}"
32 changes: 32 additions & 0 deletions bin/webhook_url_check.sh
Original file line number Diff line number Diff line change
@@ -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 "<base>/" 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