diff --git a/.github/actions/authorize-commenter/action.yml b/.github/actions/authorize-commenter/action.yml new file mode 100644 index 00000000..7acc3a86 --- /dev/null +++ b/.github/actions/authorize-commenter/action.yml @@ -0,0 +1,65 @@ +name: Authorize commenter +description: >- + Check that the author of a comment has write access to the repository, and + fail if they do not. + + Workflows triggered by `issue_comment` run from the default branch with + access to secrets, so gating them on the comment body alone lets anyone who + can comment start them. Run this action as the first job of such a workflow + and give every privileged job `needs` on that job. A failed dependency skips + the jobs that need it, so there is no condition to forget. + + This authorizes the person who wrote the comment, not the code they wrote it + on. A workflow that passes this gate and then checks out the pull request + head or merge ref runs untrusted code with the privileges the gate was + protecting. + + The default `GITHUB_TOKEN` can read collaborator permissions with no more + than `contents: read`. If a token ever cannot, this action fails, so an + underpowered token denies everyone rather than quietly authorizing them. + +inputs: + username: + description: The login to authorize. Defaults to the author of the comment + that triggered the workflow. + required: true + default: ${{ github.event.comment.user.login }} + + github-token: + description: The GitHub token to use for authentication. Defaults to the + standard GITHUB_TOKEN. + required: true + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Check the commenter's repository permission + uses: actions/github-script@v9 + env: + USERNAME: ${{ inputs.username }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { USERNAME } = process.env; + + if (!USERNAME) { + return core.setFailed('No username to authorize. Pass `username`, or run this action on an event that has a comment author.'); + } + + // `permission` collapses custom roles onto admin, write, read and + // none, so admin and write are exactly the logins that can push. + // Everyone else reads back as `read` on a public repository and + // `none` on a private one. A login that no longer resolves, such as + // `ghost`, raises instead, which fails this step rather than + // authorizing. + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: USERNAME, + }); + + if (!['admin', 'write'].includes(data.permission)) { + return core.setFailed(`@${USERNAME} has ${data.permission} permission on this repository, which is not enough to run this command.`); + } + + core.info(`@${USERNAME} has ${data.permission} permission.`); diff --git a/.github/workflows/test-authorize-commenter.yml b/.github/workflows/test-authorize-commenter.yml new file mode 100644 index 00000000..b3e13f7b --- /dev/null +++ b/.github/workflows/test-authorize-commenter.yml @@ -0,0 +1,37 @@ +name: Test "Authorize Commenter" Action + +# Unlike the other test-*.yml workflows, this one triggers directly rather than +# through workflow_call, so that it actually runs on every pull request. + +on: pull_request + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Skipped on forks, where the author has read access and would be refused. + - name: A login with write access is authorized + if: github.event.pull_request.head.repo.fork == false + uses: ./.github/actions/authorize-commenter + with: + username: ${{ github.actor }} + + - name: A login without write access is refused + id: refused + continue-on-error: true + uses: ./.github/actions/authorize-commenter + with: + username: octocat + + - name: Fail if the refusal did not happen + if: steps.refused.outcome != 'failure' + run: | + echo "Expected octocat to be refused, but the action succeeded." + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3219e3ae..6d88377e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `authorize-commenter` action, which fails unless the author of a comment has write access to the repository, so that `issue_comment` triggered workflows can refuse to run privileged jobs for someone who only has read access ([#286](https://github.com/MetaMask/github-tools/pull/286)) + ### Fixed - Count changed lines in `pr-line-check` from the pull request files API, so the count always reflects the pull request's current base branch ([#273](https://github.com/MetaMask/github-tools/pull/273))