This skill covers hardening GitHub Actions workflows against supply chain attacks, credential theft, and privilege escalation. It addresses pinning actions to SHA digests, minimizing GITHUB_TOKEN permissions, protecting secrets from exfiltration, preventing script injection in workflow expressions, and implementing required reviewers for workflow changes.
This skill covers hardening GitHub Actions workflows against supply chain attacks, credential theft, and privilege escalation. It addresses pinning actions to SHA digests, minimizing GITHUB_TOKEN permissions, protecting secrets from exfiltration, preventing script injection in workflow expressions, and implementing required reviewers for workflow changes.
When GitHub Actions is the CI/CD platform and workflows need hardening against supply chain attacks
When workflows handle secrets, deploy to production, or have elevated permissions
When preventing script injection via untrusted PR titles, branch names, or commit messages
When requiring audit trails and approval gates for workflow modifications
When third-party actions pose supply chain risk through mutable version tags
Do not use for securing other CI/CD platforms (see platform-specific hardening guides), for application vulnerability scanning (use SAST/DAST), or for secret detection in code (use Gitleaks).
Common Misconfigurations & Verification
Pinned by tag, not SHA:actions/checkout@v4 is mutable — a compromised tag pulls attacker code on the next run. Pin to the full 40-char commit SHA (@b4ffde6...) and let Dependabot bump it. Audit with grep -rE 'uses:.*@v[0-9]' across .github/workflows/.
permissions left at default / write-all: with no explicit permissions: block the GITHUB_TOKEN inherits broad write scope. Set permissions: {} at the top and grant contents: read (plus id-token: write only where OIDC is used) per job. Flag any workflow requesting write-all.
pull_request_target + checking out PR head: this runs untrusted fork code with secrets and base-repo permissions — the classic exfiltration path. Never actions/checkout with ref: github.event.pull_request.head.sha under pull_request_target; gate on a label and check out the base only.
Script injection via expressions:run: echo "${{ github.event.pull_request.title }}" lets a crafted title execute commands. Pass untrusted input through env: vars and reference "${PR_TITLE}" in the script.
Secrets echoed or passed on argv: avoid echo "$SECRET" and command-line flags; use process substitution or files. Masking only catches exact matches.
Verify by introducing a finding: open a PR titled "; echo pwned # (or one that reverts a SHA pin) and confirm actionlint / your gate flags it and the step does not execute the injected command. A workflow that runs clean on a malicious title is still vulnerable.
Prerequisites
GitHub repository with GitHub Actions enabled
GitHub organization admin access for organization-level settings
Understanding of GitHub Actions workflow syntax and events
Workflow
Step 1: Pin Actions to SHA Digests
# INSECURE: Mutable tag can be overwritten by attacker-uses:actions/checkout@v4# SECURE: Pinned to immutable SHA digest-uses:actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11# v4.1.1# Use Dependabot to auto-update pinned SHAs# .github/dependabot.ymlversion:2updates:-package-ecosystem:"github-actions"directory:"/"schedule:interval:"weekly"commit-message:prefix:"ci"
Step 2: Minimize GITHUB_TOKEN Permissions
# Set restrictive default permissions at workflow levelname:CIPipelinepermissions: {} # Start with no permissionson: [push, pull_request]
jobs:build:runs-on:ubuntu-latestpermissions:contents:read# Only what's neededsteps:-uses:actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11deploy:runs-on:ubuntu-latestneeds:buildif:github.ref=='refs/heads/main'permissions:contents:readdeployments:writeid-token:write# For OIDC-based cloud authsteps:-name:Deployrun:echo"deploying"
Step 3: Prevent Script Injection
# VULNERABLE: User-controlled input in run step-run:echo"PR title is ${{ github.event.pull_request.title }}"# SECURE: Use environment variable (properly escaped by shell)-name:ProcessPRenv:PR_TITLE:${{github.event.pull_request.title}}PR_BODY:${{github.event.pull_request.body}}run:|
echo "PR title is ${PR_TITLE}"
echo "PR body is ${PR_BODY}"
# SECURE: Use actions/github-script for complex operations-uses:actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdeawith:script:|
const title = context.payload.pull_request.title;
console.log(`PR title: ${title}`);
Step 4: Secure Fork Pull Request Handling
# DANGEROUS: pull_request_target runs with base repo permissions# on: pull_request_target # AVOID unless absolutely necessary# SAFE: pull_request runs in fork context with limited permissionson:pull_request:branches: [main]
# If pull_request_target is required, never checkout PR code:on:pull_request_target:types: [labeled]
jobs:safe-job:if:contains(github.event.pull_request.labels.*.name,'safe-to-test')runs-on:ubuntu-latestpermissions:contents:readsteps:# NEVER do: actions/checkout with ref: ${{ github.event.pull_request.head.sha }}-uses:actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11# This checks out the BASE branch, not the PR
Step 5: Protect Secrets and Environment Variables
jobs:deploy:runs-on:ubuntu-latestenvironment:production# Requires approvalsteps:-name:Deploywithsecretenv:# Secrets are masked in logs automaticallyDEPLOY_KEY:${{secrets.DEPLOY_KEY}}run:|
# Never echo secrets
# echo "$DEPLOY_KEY" # BAD
deploy-tool --key-file <(echo "$DEPLOY_KEY")
-name:Auditsecretaccessrun:|
# Log that secret was used without exposing it
echo "::notice::Deploy key accessed for production deployment"
Step 6: Implement Workflow Change Controls
# Require CODEOWNERS approval for workflow changes# .github/CODEOWNERS.github/workflows/@security-team@platform-team.github/actions/@security-team@platform-team# Organization settings:# 1. Settings > Actions > General > Fork PR policies# - Require approval for first-time contributors# - Require approval for all outside collaborators# 2. Settings > Actions > General > Workflow permissions# - Read repository contents and packages permissions# - Do NOT allow GitHub Actions to create and approve PRs
Key Concepts
Term
Definition
SHA Pinning
Referencing GitHub Actions by their immutable commit SHA instead of mutable version tags
Script Injection
Attack where untrusted input (PR title, branch name) is interpolated into shell commands
GITHUB_TOKEN
Automatically generated token with configurable permissions scoped to the current repository
pull_request_target
Dangerous event trigger that runs in the base repo context with full permissions on fork PRs
Environment Protection
GitHub feature requiring manual approval before jobs accessing an environment can run
CODEOWNERS
File defining required reviewers for specific paths including workflow files
OIDC Federation
Using GitHub's OIDC token to authenticate to cloud providers without storing long-lived credentials
Tools & Systems
Dependabot: Automated dependency updater that keeps pinned action SHAs current
StepSecurity Harden Runner: GitHub Action that monitors and restricts outbound network calls from workflows
actionlint: Linter for GitHub Actions workflow files that detects security issues
allstar: GitHub App by OpenSSF that enforces security policies on repositories
scorecard: OpenSSF tool that evaluates supply chain security practices including CI/CD
Common Scenarios
Scenario: Preventing Supply Chain Attack via Compromised Third-Party Action
Context: A widely-used GitHub Action is compromised and its v3 tag is updated to include credential-stealing code. Repositories using @v3 automatically pull the malicious version.
Approach:
Pin all actions to SHA digests immediately across all repositories
Configure Dependabot for github-actions ecosystem to manage SHA updates
Restrict GITHUB_TOKEN permissions so even compromised actions have minimal access
Add StepSecurity harden-runner to detect anomalous outbound network calls
Review all third-party actions and replace unnecessary ones with inline scripts
Require CODEOWNERS approval for any changes to .github/workflows/
Pitfalls: SHA pinning without Dependabot means missing legitimate security updates to actions. Overly restrictive permissions can break legitimate workflows. Using pull_request_target for label-based gating still exposes secrets if the workflow checks out PR code.
Output Format
GitHub Actions Security Audit
================================
Repository: org/web-application
Date: 2026-02-23
WORKFLOW ANALYSIS:
Total workflows: 8
Total action references: 34
SHA PINNING:
[FAIL] 12/34 actions use mutable tags instead of SHA digests
- .github/workflows/ci.yml: actions/setup-node@v4
- .github/workflows/deploy.yml: aws-actions/configure-aws-credentials@v4
PERMISSIONS:
[FAIL] 3/8 workflows have no explicit permissions (inherit default)
[WARN] 1/8 workflows request write-all permissions
SCRIPT INJECTION:
[FAIL] 2 workflow steps interpolate user input directly
- .github/workflows/pr-check.yml:23: ${{ github.event.pull_request.title }}
SECRETS:
[PASS] No secrets exposed in workflow logs
[PASS] All production deployments use environment protection
SCORE: 6/10 (Remediate 5 HIGH findings)