← Actions Self-Hosted Exposure Check

Self-hosted GitHub Actions runners and public repositories don't mix

A workflow triggered by pull_request runs the workflow file as it exists on the pull request's own branch, not your base branch. If a job in that workflow runs on a self-hosted label, the pull request's author controls the steps that execute on your runner machine.

Most self-hosted setups start innocently: a repository needs a GPU, a licensed compiler, or access to an internal network that a GitHub-hosted VM cannot reach, so a job gets runs-on: self-hosted.

name: build
on: pull_request
jobs:
  build:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - run: ./build.sh

On a private repository, every contributor is already trusted, so this is fine. On a public repository, anyone can open a pull request — and because the trigger is plain pull_request, GitHub runs the version of this workflow file committed on the pull request's own branch. The fork's author can rewrite the steps: entirely: read files on the runner, reach whatever network the runner can reach, or install a persistent backdoor if the runner is not rebuilt between jobs.

1. Prefer a GitHub-hosted runner whenever the job allows it

If the job does not genuinely need something only your own machine has, move it back to a GitHub-hosted label:

jobs:
  build:
    runs-on: ubuntu-latest

Each GitHub-hosted job runs in a fresh, ephemeral VM, so there is no persistent machine for a fork's workflow to compromise.

2. If you genuinely need self-hosted, restrict the trigger

Keep the self-hosted job, but move it off a trigger a fork can raise. A push to a branch only your own collaborators can push to, or a manual dispatch, does not hand a stranger control:

on:
  push:
    branches: [main]
  workflow_dispatch:

3. Require approval for fork pull requests

GitHub's own setting under Settings → Actions → General → "Fork pull request workflows" can require a maintainer to approve every run from an outside contributor, or from anyone who is not already a known collaborator. This does not change what a workflow file could do once approved — it adds a human checkpoint before a fork's code ever reaches the runner.

4. pull_request_target and workflow_run are a different, still real, risk

These two triggers always run the workflow file from your base branch, so a fork cannot rewrite the steps. But they still respond to fork-influenced events, often with repository secrets available to the job, and a self-hosted runner combined with a step that checks out and executes the fork's own code (the "pwn request" pattern) still runs that code on infrastructure you own rather than a disposable VM. If a self-hosted job under one of these triggers also checks out an untrusted head, treat it with the same seriousness as plain pull_request.

5. Check every workflow at once

Paste your workflow YAML into Actions Self-Hosted Exposure Check to find every job that runs on a self-hosted label under one of the four risky triggers, across every 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 job is fork-exposed by this tool's definition — wire it into your own CI to block a newly-added exposed job before it merges.

What this does not tell you

This tool cannot see whether your repository is actually public, whether required-approval settings are already on, or whether a named runner group (runs-on: {group: ...}) is self-hosted — it flags only the literal self-hosted label. A clean scan is not a claim that your self-hosted infrastructure is otherwise hardened.