From 05643d628a995083a965cc8ff8b720d38c0e88af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:05:02 +0000 Subject: [PATCH] Harden CI supply chain and add an org-wide security policy Addresses findings from a security review of this repository. These workflows are consumed by ~45 repositories in the organization, so changes here apply org-wide. Rebased onto #274, which independently pinned actionlint by digest and added `--fail` to the two curl calls in the code quality workflow. Those parts of the review are covered by that change and are dropped here; #274's actionlint pin resolves to the linux/amd64 manifest of v1.7.11, which is correct for the GitHub-hosted runners these jobs use. Add SECURITY.md This repository provides the organization's default community health files, but had no security policy, so no wp-cli repository surfaced a "Report a vulnerability" path. Points at the WP-CLI handbook and the WordPress HackerOne program rather than restating policy that lives elsewhere. Scope `actions: write` to the job that needs it It was declared at workflow level, so it also reached `triage-new-item`, which runs on `pull_request_target` and processes pull request titles and bodies written by anyone who can open a PR. `actions: write` permits dispatching workflows and deleting caches and artifacts, a known lateral-movement path. Only `triage-unlabeled-items` needs it, and that job is `workflow_dispatch`-only. The grant has to stay in the caller, since a caller can only cap a reusable workflow's permissions and never raise them; removing it there is what broke dispatching in #271 and prompted the revert in #272. Comments in both files record this so the next attempt narrows rather than removes. Pin gherkin-lint `npx --yes gherkin-lint` resolved and executed the newest publish on every run. The package was last released in December 2023 and has two maintainers, so a single account compromise would reach CI in every repository. Correct the actions/setup-node pin comment The pinned SHA is v7.0.0, but the trailing comment read v6. The pin itself is immutable and current; the comment is what reviewers read, so a wrong one quietly defeats the point of the convention. Fail the WP-CLI download loudly `curl -O` without `-f` writes the error body to the file and still exits 0, so an outage installed an HTML page as /usr/local/bin/wp and surfaced as a confusing failure much later. Reduce blast radius of the workflow sync SKIP_DELETE stops a pattern change from deleting files across every target repository. The FILE_PATTERNS regexes now escape their dots so they match only the intended paths. Both jobs drop to `contents: read`: the sync authenticates with ACTIONS_BOT, and the action reads that token from its `with:` input and makes no API calls, so GITHUB_TOKEN is never used at all. --- .github/workflows/issue-triage.yml | 7 ++- .github/workflows/reusable-code-quality.yml | 6 ++- .github/workflows/reusable-issue-triage.yml | 9 +++- .../workflows/reusable-regenerate-readme.yml | 5 +- .github/workflows/sync-workflows.yml | 34 +++++++++----- SECURITY.md | 47 +++++++++++++++++++ 6 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 SECURITY.md diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 6833470..bf816ae 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -16,9 +16,14 @@ name: Issue and PR Triage permissions: issues: write pull-requests: write - actions: write contents: read models: read + # A caller can only cap a reusable workflow's permissions, never raise them, + # so `actions: write` has to be granted here for the dispatch job downstream + # to work at all. The reusable workflow narrows it to that single job, so the + # `pull_request_target` job does not receive it. Removing it here breaks + # dispatching (see #271/#272); narrow it there instead. + actions: write jobs: issue-triage: diff --git a/.github/workflows/reusable-code-quality.yml b/.github/workflows/reusable-code-quality.yml index 5d1a81f..00290c9 100644 --- a/.github/workflows/reusable-code-quality.yml +++ b/.github/workflows/reusable-code-quality.yml @@ -122,7 +122,7 @@ jobs: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Setup node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 'lts/*' @@ -132,7 +132,9 @@ jobs: run: curl --fail --silent --show-error https://raw.githubusercontent.com/wp-cli/.github/refs/heads/main/.gherkin-lintrc -o "$RUNNER_TEMP/.gherkin-lintrc" - name: Run linter - run: npx --yes gherkin-lint -c "$RUNNER_TEMP/.gherkin-lintrc" + # Pinned: unpinned `npx` resolves and executes the newest publish at run + # time, and gherkin-lint has not been released since 2023. + run: npx --yes gherkin-lint@4.2.4 -c "$RUNNER_TEMP/.gherkin-lintrc" lint-spellcheck: name: Spell check diff --git a/.github/workflows/reusable-issue-triage.yml b/.github/workflows/reusable-issue-triage.yml index 31df4ba..02f051d 100644 --- a/.github/workflows/reusable-issue-triage.yml +++ b/.github/workflows/reusable-issue-triage.yml @@ -12,7 +12,6 @@ name: Issue and PR Triage permissions: issues: write pull-requests: write - actions: write contents: read models: read @@ -161,6 +160,14 @@ jobs: github.event_name == 'workflow_dispatch' && inputs.issue_number == '' runs-on: ubuntu-latest + # `actions: write` is only needed to dispatch triage runs, which happens + # here and nowhere else. Scoping it to this job keeps it out of the token + # handed to `triage-new-item`, which runs on `pull_request_target` and + # processes pull request content written by anyone who can open a PR. + permissions: + actions: write + issues: read + contents: read steps: - name: Find and dispatch triage for unlabeled items uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/reusable-regenerate-readme.yml b/.github/workflows/reusable-regenerate-readme.yml index 1631b1f..24925a5 100644 --- a/.github/workflows/reusable-regenerate-readme.yml +++ b/.github/workflows/reusable-regenerate-readme.yml @@ -46,7 +46,10 @@ jobs: - name: Install WP-CLI run: | - curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli-nightly.phar + # `-f` matters most here: without it curl writes the error body to the + # file and still exits 0, so an outage silently installs an HTML page + # as /usr/local/bin/wp and the failure surfaces much later. + curl -fsSL -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli-nightly.phar sudo mv wp-cli-nightly.phar /usr/local/bin/wp sudo chmod +x /usr/local/bin/wp diff --git a/.github/workflows/sync-workflows.yml b/.github/workflows/sync-workflows.yml index fab9d94..3382f05 100644 --- a/.github/workflows/sync-workflows.yml +++ b/.github/workflows/sync-workflows.yml @@ -41,22 +41,26 @@ jobs: # run cut short here is completed by the next push or the daily schedule. timeout-minutes: 60 if: ${{ github.repository_owner == 'wp-cli' }} + # The sync authenticates with the ACTIONS_BOT token below, so GITHUB_TOKEN + # is never used to write anything and does not need `contents: write`. permissions: - contents: write + contents: read steps: - name: Sync files uses: adrianjost/files-sync-action@55d577a25b7106ebc3001353d981ecf8b0150df4 # v2.1.0 with: + # Patterns are regexes matched against repo-relative paths, so the + # dots are escaped to stop them matching any character. FILE_PATTERNS: | - ^.actrc - ^.editorconfig - ^.github/workflows/copilot-setup-steps.yml - ^.github/workflows/regenerate-readme.yml - ^.github/workflows/welcome-new-contributors.yml - ^.github/workflows/issue-triage.yml - ^.github/workflows/check-branch-alias.yml - ^.github/workflows/manage-labels.yml - ^AGENTS.md + ^\.actrc + ^\.editorconfig + ^\.github/workflows/copilot-setup-steps\.yml + ^\.github/workflows/regenerate-readme\.yml + ^\.github/workflows/welcome-new-contributors\.yml + ^\.github/workflows/issue-triage\.yml + ^\.github/workflows/check-branch-alias\.yml + ^\.github/workflows/manage-labels\.yml + ^AGENTS\.md TARGET_REPOS: | wp-cli/ai-command wp-cli/ability-command @@ -103,6 +107,9 @@ jobs: wp-cli/wp-config-transformer wp-cli/wp-super-cache-cli GITHUB_TOKEN: ${{ secrets.ACTIONS_BOT }} + # Only add and update; never delete a file in a target repo just + # because it is absent here. + SKIP_DELETE: true sync-dependabot: name: Sync Dependabot configuration @@ -113,13 +120,13 @@ jobs: timeout-minutes: 60 if: ${{ github.repository_owner == 'wp-cli' }} permissions: - contents: write + contents: read steps: - name: Sync files uses: adrianjost/files-sync-action@55d577a25b7106ebc3001353d981ecf8b0150df4 # v2.1.0 with: FILE_PATTERNS: | - ^.github/dependabot.yml + ^\.github/dependabot\.yml TARGET_REPOS: | wp-cli/ai-command wp-cli/ability-command @@ -168,3 +175,6 @@ jobs: wp-cli/wp-config-transformer wp-cli/wp-super-cache-cli GITHUB_TOKEN: ${{ secrets.ACTIONS_BOT }} + # Only add and update; never delete a file in a target repo just + # because it is absent here. + SKIP_DELETE: true diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..794e63d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security Policy + +WP-CLI is used to administer WordPress sites in production, frequently with +elevated privileges, so we take security reports seriously. + +This is the organization-wide default policy for repositories in the +[`wp-cli` organization](https://github.com/wp-cli). A repository that publishes +its own `SECURITY.md` overrides this one. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, +pull requests, or discussions.** + +Report them through the WordPress bug bounty program on HackerOne, which covers +WP-CLI alongside WordPress core and related projects: + + + +Before you submit, please read the full guidance in the WP-CLI handbook: + + + +That handbook page is the authoritative description of what we treat as a +vulnerability, what to include in a report, and what to expect during +coordinated disclosure. A valid report may be eligible for a CVE and a bounty. + +## What makes a report actionable + +A vulnerability report needs to show how someone outside a trust boundary gains +something they could not otherwise obtain. Please state plainly: + +- who the attacker is and what access they begin with, +- what they gain that they should not have, +- the steps to reproduce it. + +Reports that do not demonstrate an actual exploit are likely to be declined. +Note that WP-CLI runs as a trusted local user by design: someone who can already +execute `wp` on a server can generally already act as that user, so that alone +is not a privilege boundary. + +## Build and release infrastructure + +Reports about this organization's shared CI configuration — the reusable +GitHub Actions workflows in [`wp-cli/.github`](https://github.com/wp-cli/.github) +and the credentials they use — are in scope and are best sent through the same +channel above.