Coordinated Disclosure Timeline

Summary

The assign-reviewer-review-apply workflow downloads and extracts an artifact produced by a fork-controlled workflow directly into a workspace containing trusted executable code. An external pull request author can place a replacement .github/workflows/scripts/assign_reviewer_lib.js in that artifact. The privileged workflow_run job subsequently imports the overwritten module before validating the artifact data, resulting in arbitrary JavaScript execution with a GitHub token that has issues: write and pull-requests: write permissions.

Project

spiffe/spire repository

Tested Version

Observed on the default branch at commit ef8527ec91013c6c1b4741e0f6ac0786576e620e.

Details

Poisoned workflow artifact overwrites a trusted helper and enables code execution in a privileged job (GHSL-2026-204)

The assign-reviewer-review-capture workflow runs whenever a pull request review is submitted, without filtering by review state or submitter. GitHub prevents a pull request author from approving their own pull request, but the author can submit a comment-only review. That action produces a submitted review with state commented, which the apply workflow accepts for the pull request author. Consequently, an external contributor can trigger the capture workflow on their own pull request without another reviewer; a maintainer may only need to approve the fork workflow run under the repository’s GitHub Actions policy. For a pull request from a fork, the external contributor can also modify the pull request’s copy of the capture workflow. Although the producer receives only a read-scoped token, it can upload attacker-chosen files under the expected artifact name assign-reviewer-review instead of the intended review.json:

on:
  pull_request_review:
    types: [submitted]

permissions:
  contents: read

jobs:
  capture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const fs = require('fs');
            const review = context.payload.review;
            const data = {
              pr_number: context.payload.pull_request.number,
              review_state: (review.state || '').toLowerCase(),
              submitter: review.user.login,
            };
            fs.writeFileSync('review.json', JSON.stringify(data));
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: assign-reviewer-review
          path: review.json

Successful completion of the named capture workflow triggers the default-branch assign-reviewer-review-apply workflow. This privileged consumer has write access to upstream issues and pull requests:

on:
  workflow_run:
    workflows: [assign-reviewer-review-capture]
    types: [completed]

jobs:
  apply:
    if: github.event.workflow_run.conclusion == 'success'
    permissions:
      contents: read
      issues: write
      pull-requests: write
      actions: read

The apply job first sparse-checks out the trusted workflow scripts. It then downloads and extracts the artifact into the same workspace without specifying an isolated path:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    persist-credentials: false
    sparse-checkout: .github/workflows/scripts

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  with:
    name: assign-reviewer-review
    run-id: ${{ github.event.workflow_run.id }}
    github-token: ${{ github.token }}

Because the fork-controlled producer determines the artifact entries, it can include .github/workflows/scripts/assign_reviewer_lib.js. Extraction into the workspace replaces the trusted helper that was just checked out.

The following actions/github-script step imports and invokes that helper before reading or validating review.json:

const fs = require('fs');
const courtLib = require('./.github/workflows/scripts/assign_reviewer_lib.js');
const court = await courtLib({ github, context, core });
const { owner, repo } = context.repo;

// Artifact field validation starts only after the module has executed.
let raw;
try {
  raw = JSON.parse(fs.readFileSync('review.json', 'utf8'));
} catch (e) {
  core.warning(`Could not read captured review data: ${e.message}`);
  return;
}

The later checks validate pr_number, review_state, submitter, the pull request head SHA, and the existence of the claimed review. Those checks constrain attacks that only forge review.json, but they cannot protect a helper module that has already executed.

A representative attack sequence is:

  1. An external contributor modifies the capture workflow in a fork pull request so that a successful run uploads an artifact named assign-reviewer-review containing a malicious .github/workflows/scripts/assign_reviewer_lib.js.
  2. The contributor submits a comment-only review on their own pull request. This creates a pull_request_review: submitted event with state commented and triggers the modified capture workflow, subject only to any GitHub Actions approval required for the fork run; no approving review or separate reviewer is required.
  3. Successful completion automatically triggers the default-branch workflow_run apply job with issue and pull-request write permissions.
  4. The apply job checks out the trusted helper, then extracts the attacker-controlled artifact over the same workspace.
  5. actions/github-script imports the replaced helper and supplies its authenticated github client before any artifact-data validation occurs.

Impact

An external fork pull request author can execute arbitrary JavaScript in the privileged apply job and use its authenticated GitHub client to perform operations allowed by issues: write and pull-requests: write. This compromises the integrity of upstream issues and pull requests, including labels, assignees, and other writable issue or pull request state.

CWEs

Resources

Credit

This issue was discovered and reported by GHSL team member @JarLob (Jaroslav Lobačevski).

Contact

You can contact the GHSL team at securitylab@github.com. Please include a reference to GHSL-2026-204 in any communication regarding this issue.