Posted in

GitHub Actions Security: Hardening Your Workflow Files Against Supply Chain Attacks

GitHub Actions workflows are the CI/CD infrastructure for a majority of software projects. They also represent a significant and often underappreciated attack surface in the software supply chain. A compromised workflow can exfiltrate secrets, inject malicious code into build artifacts, or alter the images that get deployed to production.

The security properties of your container images depend in part on the integrity of the workflow that builds, scans, and pushes them. A workflow that can be tampered with is a workflow where tampered images can enter your registry.


The GitHub Actions Threat Model

GitHub Actions workflows are YAML files in your repository. They execute on GitHub-managed infrastructure (or self-hosted runners) with access to repository secrets, the ability to push to registries, and often the ability to push code. The threat vectors:

Compromised third-party Actions: When a workflow references a third-party Action (uses: some-vendor/some-action@v2), it executes code from that external repository. If that repository is compromised, the malicious code runs with the permissions of your workflow — including access to your secrets.

Mutable version references: @v2 is a mutable reference — it resolves to whatever the Action author has pointed that tag to at execution time. An Action that is legitimate today can be replaced with malicious code tomorrow without changing any reference in your workflow.

Excessive workflow permissions: Workflows that have write access to repository contents, packages, or deployments can be abused if an attacker can modify the workflow file. Overly permissive tokens amplify the blast radius.

Secret exposure: Environment variables, env context, and command outputs can inadvertently log secrets. In some cases, PR-triggered workflows expose secrets to pull requests from forked repositories.


Hardening Third-Party Action References

The most effective defense against compromised Actions: pin to commit SHA rather than mutable tags.

# Vulnerable: mutable tag reference

steps:

  – uses: actions/checkout@v4

  – uses: some-vendor/security-scanner@v2

# Hardened: immutable SHA reference

steps:

  – uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

  – uses: some-vendor/security-scanner@a3b8c2d1e4f5678901234567890abcdef123456  # v2.1.0

SHA pinning guarantees that the code executing in your workflow is exactly the code you reviewed when you pinned it. A tag pointing to different code at a later time has no effect on SHA-pinned references.

The operational overhead: when you want to update a pinned Action, you explicitly update the SHA and verify what changed in that commit range. This is the same review process you should apply to any dependency update.


Least-Privilege Workflow Permissions

Workflow permissions should be explicitly set to the minimum required for the specific workflow’s purpose. The default token permissions in GitHub Actions are configurable at the organization, repository, and workflow level.

# Workflow-level permissions: explicit and minimal

name: Container Build and Push

permissions:

  contents: read          # Read repository for checkout

  packages: write         # Push to GitHub Container Registry

  id-token: write         # Required for cosign OIDC signing

  security-events: write  # Required for SARIF upload to Security tab

jobs:

  build:

    runs-on: ubuntu-latest

    permissions:

      contents: read      # Job-level can be more restrictive than workflow-level

      packages: write

Workflows that build and push container images typically need: contents: read, packages: write, and id-token: write for signing. Any permissions beyond this should be explicitly justified.


Secure Secret Handling in Build Workflows

Workflows that build container images often need access to credentials: registry tokens, signing keys, external service API keys. The secure handling pattern:

– name: Log in to registry

  uses: docker/login-action@v3

  with:

    registry: ghcr.io

    username: ${{ github.actor }}

    password: ${{ secrets.GITHUB_TOKEN }}  # Not a stored secret, generated per run

# Avoid: printing secrets to logs

– name: Verify connection (unsafe)

  run: echo “Token: ${{ secrets.MY_SECRET }}”  # NEVER do this

# Safe: use secrets without exposing them

– name: Configure tool

  env:

    TOOL_SECRET: ${{ secrets.MY_TOOL_SECRET }}

  run: tool-configure –secret-from-env TOOL_SECRET

The GITHUB_TOKEN is scoped to the current repository and expires after the workflow completes. Prefer it over long-lived tokens stored as repository secrets where possible.


Image Hardening as a Workflow Security Control

A hardened container image provides a security guarantee that is independent of the workflow that produced it. Even if workflow logs are compromised or workflow execution is partially tampered with, an image that was hardened and signed before push carries evidence of its security state that persists through the registry.

Secure software supply chain workflows that include hardening as a mandatory step before the image signing and push steps produce a defense-in-depth property: the signed artifact’s contents have been verified and hardened, and the signature is evidence that this step occurred.

– name: Build image

  run: docker build -t $IMAGE:$SHA .

– name: Harden image

  run: harden-image $IMAGE:$SHA $IMAGE:hardened-$SHA

– name: Scan hardened image

  run: scan-image $IMAGE:hardened-$SHA –exit-code 1 –severity CRITICAL

# Only sign if scan passed

– name: Sign image

  if: success()

  run: cosign sign $IMAGE:hardened-$SHA

– name: Push to registry

  if: success()

  run: docker push $IMAGE:hardened-$SHA

The signing step after hardening creates a verifiable record that the image was hardened before deployment. Admission controllers that verify both the signature and the associated hardening attestation provide deployment enforcement that is independent of workflow integrity.



Frequently Asked Questions

What is the biggest GitHub Actions security risk for container build workflows?

The highest-impact GitHub Actions security risk is the use of mutable third-party Action references — tags like @v2 that resolve to whatever code the Action author has pointed that tag to at execution time. A legitimate Action referenced with a mutable tag can be replaced with malicious code without any change to your workflow file. Pinning all third-party Actions to immutable commit SHAs eliminates this attack vector: the code executing in your workflow is exactly the code you reviewed when you pinned it, regardless of what the Action author does later.

How should workflow permissions be configured for GitHub Actions that build container images?

Workflow permissions for container build workflows should be set explicitly and minimally at both the workflow and job level. A workflow that builds and pushes a container image typically requires contents: read (for repository checkout), packages: write (for registry push), and id-token: write (for cosign OIDC signing). Any permissions beyond this should be explicitly justified. Default GitHub token permissions are often broader than needed; setting explicit minimal permissions at the workflow level limits the blast radius if any step in the workflow is compromised.

How does image hardening improve GitHub Actions security posture?

Image hardening adds a security guarantee that is independent of the workflow’s integrity. Even if workflow execution is partially tampered with, a hardened container image that was signed after hardening carries persistent evidence of its security state through the registry. Admission controllers that verify both the cosign signature and the hardening attestation at deployment enforce this guarantee at the cluster level — ensuring that only images produced by the authorized hardening pipeline can be scheduled, regardless of what happened in the workflow that produced them.

What is workflow injection in GitHub Actions and how is it prevented?

Workflow injection occurs when user-controlled data — pull request titles, issue body text, branch names — reaches a workflow expression and is executed as shell code rather than treated as a string. The prevention is to pass user-controlled values through an intermediate environment variable rather than directly into run expressions. Setting env: PR_TITLE: ${{ github.event.pull_request.title }} and then referencing “$PR_TITLE” in the shell command ensures the shell interprets the value as a string, not as executable code, regardless of what an attacker puts in the PR title.


Protecting Against Workflow Injection

Workflow injection attacks occur when user-controlled data (pull request titles, issue bodies, branch names) reaches an expression in a workflow that executes it as code. The protection:

# Vulnerable: PR title used in expression

– name: Comment on PR

  run: gh pr comment ${{ github.event.pull_request.title }}  # Could inject shell commands

# Safe: Use an intermediate environment variable

– name: Comment on PR

  env:

    PR_TITLE: ${{ github.event.pull_request.title }}

  run: gh pr comment “$PR_TITLE”  # Shell interprets as string, not as command

Container image security is not the only security dimension in a CI/CD pipeline. Workflow security — preventing injection, limiting permissions, pinning dependencies, verifying artifacts — is the upstream layer that determines whether the security steps in the workflow are trustworthy.

Hardening the workflow files that build container images is part of the same security program as hardening the images themselves.