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.