← Actions Self-Hosted Exposure Check
Actions Self-Hosted Exposure Check vs. reviewing workflows by eye
Reading a workflow file for a self-hosted runner under a risky trigger is a real skill, and an experienced reviewer catches the obvious case every time. This page names no product or company -- "reviewing by eye" is the generic habit every maintainer already has, not a competing tool.
Where a careful read holds up, and where it slips
A job with runs-on: self-hosted sitting right below an on: pull_request_target
trigger is easy to spot once you know the shape. What's harder to hold in your head while scanning a real
repository's workflow files: self-hosted can show up as one label among several in a
runs-on: list rather than the whole value; workflow_run and issue_comment
carry the same fork-influenced risk as pull_request_target without "pull request" in the name to
cue the reviewer; a second job further down the same file can have its own, different runs-on:
after the first job already looked clean; and a runner group or a ${{ }} matrix expression can't
be judged safe or unsafe from the file text at all, which is easy to either wave through or flag on a hunch.
| Case | Reviewing by eye | This tool |
|---|---|---|
Bare runs-on: self-hosted under pull_request_target | Easy to catch | Catches it the same way |
runs-on: [self-hosted, linux, x64] under the same trigger | Easy to miss when self-hosted is one item in a list rather than the whole line | Parses every label in flow-list, block-list, or single-value form |
Trigger is workflow_run or issue_comment rather than pull_request_target | Easy to read past -- review habit is trained on pull_request_target, and neither name says "pull request" | Checked against the same fixed four-trigger set regardless of which one is top of mind |
A second job further down the same workflow file, with its own runs-on: | Easy to skip once the first job in the file already looked clean | Every job block in the file is found and classified independently |
runs-on: selects a runner group by name | Easy to guess safe or unsafe from the group's name alone | Reported honestly as unclassifiable from this file, not guessed either way |
runs-on: ${{ matrix.os }} (a matrix expression) | Easy to wave through since it doesn't literally say self-hosted | Flagged as unable to classify statically rather than silently passed as safe |
| A repository with a dozen workflow files | Re-reading all of them, every time a workflow changes, is tedious and easy to shortcut | Paste as many files as you have, or run check.py across a whole checked-out repository |
| Blocking a newly-added finding before it merges | Depends on a human noticing it in review | check.py --fail-on-risk gives a real exit code from the same classification, wired into your own CI |
The two are complementary, not competing: this tool does not decide whether a flagged job is
actually reachable from an attacker in your specific setup, or whether the repository is even public -- it
makes no network calls and never checks that. It replaces the tedious, error-prone part of the read-through:
finding every job in every file, parsing every runs-on: form, and checking each one against the
same fixed trigger list every time.
Common questions
What does reviewing a workflow for self-hosted exposure by eye actually catch?
A reviewer who knows the pattern will catch the obvious case: a job with runs-on: self-hosted sitting a few lines below an on: pull_request_target trigger. That recognition is real, and this tool checks the same underlying combination -- a self-hosted label and one of four documented risky triggers -- rather than a wider or different rule.
Why is self-hosted easy to miss when it's inside a runs-on: list?
runs-on: can be a single label, a flow-style list like runs-on: [self-hosted, linux, x64], or a block list with one label per line. A reviewer scanning quickly for the literal line runs-on: self-hosted can walk right past self-hosted when it's the first of three items in a list, especially when the eye lands on a more distinctive label like linux or x64 first. This tool parses every label in the list, in any of the three forms, and matches self-hosted as one item among several rather than expecting the whole value to equal that one word.
Why are workflow_run and issue_comment easy to forget as risky triggers?
Most self-hosted-exposure review habit is trained on pull_request_target, because that trigger's whole reputation is built on the pwn-request pattern. workflow_run and issue_comment do not have "pull request" in the name, so a reviewer scanning a workflow's on: block for the familiar trigger can read past both without recognizing that they hand a stranger the same kind of influence GitHub's own guidance already names. This tool checks a job's runs-on: against the same fixed set of four triggers regardless of which one happens to be top of mind that day, and names the specific trigger that matched in its output.
Why is a second job in the same workflow file easy to miss?
A reviewer who reads the first job in a file, confirms its runs-on: is safe, and moves on to the next file can stop reading before a second job further down the same file -- one with its own, different runs-on: line. This tool finds every job block in a file independently and classifies each one on its own runs-on:, so a risky second job is reported even when the first job in the same file is clean.
Does a clean result from this tool mean my self-hosted runners are safe?
No. It checks one specific, narrow pattern in the workflow files you give it: the literal self-hosted label combined with one of four documented risky triggers, in the jobs it can classify statically. A runs-on: that selects a runner group by name, or one built from a ${{ }} matrix expression, is reported honestly as unclassifiable from the file alone rather than guessed either way -- and it makes no network call, so it never knows whether the repository the workflow lives in is actually public.