← Actions Artifact Poisoning Check

What is GitHub Actions artifact poisoning?

workflow_run is a trigger that fires when a separate workflow run completes — commonly used so a privileged workflow can react to an unprivileged pull_request build without inheriting the fork's ability to change what that workflow does by editing the workflow file itself. The workflow_run job runs in the context of the base repository, with the base repository's GITHUB_TOKEN and secrets — but the run it reacts to was still triggered by whoever opened the pull request, who might be a stranger.

That is a deliberate, documented design, and it is safe on its own. The risk is what the privileged job does with the artifact the unprivileged run produced:

on:
  workflow_run:
    workflows: [build-pr]
    types: [completed]
jobs:
  publish-results:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - run: |
          chmod +x ./test-results/publish.sh
          ./test-results/publish.sh

The artifact was uploaded by the fork-triggered build-pr workflow, so its contents — not just the event payload GitHub itself controls — are whatever the fork put there. Downloading it is not the risk; running it is. GitHub's own Actions Security Lab, and independent research from Wiz, Legit Security and Orca Security, document this pattern as "artifact poisoning": a fork can put a script in the artifact it uploads and rely on the privileged handler to execute it unchecked.

A second, quieter version

A download-artifact step with no name: input pulls in every artifact the triggering run uploaded, not one specific, expected one. Even if nothing in the same job executes anything directly, that widens what untrusted content is sitting on disk for a later step, or an action called by uses:, to pick up.

The fix

Check your own workflows

Paste your workflow YAML into Actions Artifact Poisoning Check to find every workflow_run job that downloads and later executes an artifact, and every unscoped download, across every workflow file at once, or run the CLI in your own repository:

python3 check.py --fail-on-risk

That exits with a nonzero status if any workflow has either pattern — wire it into your own CI to block a newly-added one before it merges.