GitHub Actions Workflow Linter

Check a GitHub Actions workflow for the mistakes that let someone else run code with your secrets, the actions pinned to a ref their author can move, and the two defaults that quietly bill for six hours.

Paste below, or drop a file anywhere on this panel

Or drop a file anywhere on this panel. Nothing is uploaded: the analysis runs in this tab.

The answer appears here

Paste on the left and press Check the workflow. Nothing leaves this tab.

Wanted a different tool?

Examples

Real input you can load into the tool above. Each one shows a different thing going wrong, because that is what the tool is for.

Fork code running with your secrets

pull_request_target runs in the base repository's context, so it has your secrets. Checking out the pull request head and building it hands them to whoever opened the pull request, with no approval step for a first-time contributor.

name: CI
on:
  pull_request_target:
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          persist-credentials: false
      - run: npm ci && npm test

A pull request title as a shell command

The expression is substituted into the script before the shell parses it, so a title containing a semicolon and a command runs that command with the runner's token.

name: Greet
on:
  pull_request:
permissions:
  contents: read
concurrency:
  group: greet
jobs:
  greet:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - run: echo "Thanks for ${{ github.event.pull_request.title }}"

A third-party action on a movable tag

A tag is a pointer its author controls. The tj-actions compromise reached thousands of repositories by retagging, and every one was pinned this way.

name: Deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
concurrency:
  group: deploy
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: some-vendor/deploy-action@v2

No timeout, so six hours is the limit

A hung job bills the full default. On a macOS runner at the ten times multiplier that is 3,600 charged minutes for one stuck run.

name: CI
on:
  push:
    branches: [main]
permissions:
  contents: read
concurrency:
  group: ci
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - run: npm test

Common mistakes

These are the ones that fail silently. The config is accepted, nothing raises an error, and the consequence arrives later.

  1. Checking out the pull request head under `pull_request_target`

    That trigger runs with the base repository's secrets and a writable token by design. Checking out the fork's code and running anything from it hands those secrets to whoever opened the pull request, with no approval step.

    Instead:Split it: a `pull_request` workflow builds untrusted code with no secrets, and a `pull_request_target` workflow only reads the result and never checks out the head.

  2. Interpolating event data straight into `run:`

    `${{ }}` is substituted into the script before the shell parses it, so a pull request title containing a semicolon and a command executes that command. Titles, bodies, comments and branch names are all written by strangers.

    Instead:Pass it through `env:` and reference `"$VAR"` quoted. The shell never re-parses an environment value.

  3. Pinning a third-party action to a tag

    A tag is a pointer its author can move. The tj-actions/changed-files compromise reached thousands of repositories by retagging, and every one was pinned exactly as the documentation suggests.

    Instead:Pin to a full 40 character commit SHA with the version in a trailing comment. Dependabot updates SHA pins.

  4. Leaving the `permissions` block out

    The token then gets the repository or organisation default, which on anything created before 2023 is read and write on every scope. The same workflow is therefore safe in one repository and not in another.

    Instead:Declare `contents: read` at the top and add exactly what each job needs at the job level.

  5. Omitting `timeout-minutes`

    The default limit is six hours. A hung test or a network call with no deadline bills 360 minutes, which on a macOS runner is 3,600 charged minutes for one stuck job.

    Instead:Set it a little above the job's normal duration. Ten to twenty minutes suits most builds.

Two of these let a stranger run code with your secrets

Most workflow problems are untidiness. Two are remote code execution, they look exactly like ordinary CI, and they are both reachable by anyone who can open a pull request.

pull_request_target plus a checkout of the fork's code

That trigger exists so a workflow can label and comment on pull requests from forks, so by design it runs in the base repository's context with your secrets and a writable token. Checking out the fork's ref and then running anything from it, a build, a test, an npm install with a lifecycle script, hands those secrets to whoever opened the pull request. There is no approval gate on this trigger for first-time contributors, which is the whole point of it and the whole problem.

on: pull_request_target        # has secrets
steps:
  - uses: actions/checkout@v4
    with:
      ref: ${{ github.event.pull_request.head.sha }}   # untrusted code
  - run: npm ci && npm test                            # runs it

Untrusted event data interpolated into run:

The expression is substituted into the script text before the shell ever sees it, so the value becomes part of the command rather than an argument to it. A pull request title, an issue body, a comment and a branch name are all written by strangers. A title of a semicolon followed by a command runs that command with the runner's token and whatever is in the environment.

run: echo "${{ github.event.pull_request.title }}"   # injectable

env:                                                  # safe
  TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE"

A tag is a pointer the author can move

Pinning a third-party action to v4 means running whatever v4 points at today. The tj-actions/changed-files compromise in 2025 reached thousands of repositories in hours by retagging, and every one of them was pinned to a tag exactly as the documentation suggests. A 40 character commit SHA cannot be moved. Dependabot updates SHA pins, so the maintenance cost is close to zero.

The default token permissions are per repository, not per workflow

With no permissions block the token gets whatever the repository or organisation default is, and on anything created before 2023 that is read and write on every scope. So a workflow that is harmless in one repository is a write-capable token in the next, and nothing in the file tells you which. Declare contents: read at the top and add what each job needs.

The two settings that cost money rather than security

A job with no timeout-minutes inherits a six hour limit, so a hung test bills for 360 minutes, or 3,600 on a macOS runner. A workflow with no concurrency group runs every push to completion, including the two that were superseded before they finished. Neither is visible until the invoice.

What this cannot see

It reads one file. It cannot see the repository's default token permissions, which is why the permissions finding is about the absence of a declaration rather than about a value, and it cannot follow a reusable workflow or a composite action to check what that does with the inputs you pass it. It does not resolve whether an action reference is actually a SHA that exists. For a workflow that calls others, check each one: a caller that declares tight permissions can still pass secrets to a called workflow that does not.