Back

Auditing GitHub Workflows for Security Risks

Auditing GitHub Workflows for Security Risks

Supply-chain attacks against GitHub Actions have moved from theoretical to routine. The March 2025 compromise of tj-actions/changed-files showed how quickly a single poisoned action can leak secrets across thousands of repositories. A year later, the pattern repeated with the Trivy-action incident, where attackers force-pushed malicious code into 76 of 77 version tags.

If you already have workflows running in production, this checklist helps you spot the most common weaknesses without redesigning your entire pipeline.

Key Takeaways

  • Set permissions: {} at the workflow level and grant only the minimum scopes each job needs.
  • Never interpolate ${{ github.event.* }} values directly into shell commands—pass them through environment variables instead.
  • Pin third-party actions to full commit SHAs and avoid combining pull_request_target with checkouts of fork code.
  • Replace static cloud credentials with OIDC, and restrict self-hosted runners from running untrusted code in public repositories.

Check Your GITHUB_TOKEN Permissions First

Open any workflow file and look at the top-level permissions block. If it’s missing, your organization’s default applies—and organizations created before February 2023 often default to read-write.

The fix is straightforward:

permissions: {}  # deny all at workflow level

jobs:
  build:
    permissions:
      contents: read  # grant only what the job needs

Setting permissions: {} at the workflow level forces you to declare exactly what each job needs. A job that only reads code should never hold a GITHUB_TOKEN with write access to your repository.

Look for Untrusted Input in Shell Commands

Search your workflow files for ${{ github.event inside run: blocks. This is the most common script injection pattern:

# Risky: attacker controls the PR title
- run: echo "Checking ${{ github.event.pull_request.title }}"

The safe alternative is to pass untrusted values through an intermediate environment variable:

- name: Check PR title
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: echo "Checking $TITLE"

The value is passed via the environment rather than interpolated into the shell script, so shell metacharacters in the input cannot break out and execute commands. Also watch for writes to GITHUB_ENV and GITHUB_PATH in steps that handle user-controlled content—attackers can use these to inject environment variables or malicious binaries into later steps.

Audit pull_request_target Usage

pull_request_target runs with access to base-branch secrets, which makes it useful for workflows that need to comment on PRs from forks. The risk isn’t the trigger itself—it’s combining it with a checkout of the fork’s code:

# Dangerous combination
on: pull_request_target
jobs:
  test:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # runs attacker code
      - run: npm test  # with access to your secrets

If you use pull_request_target, ensure no step executes code from the PR branch. GitHub partially mitigated this in late 2025, but the trigger remains high-risk when combined with fork checkouts.

Review Third-Party Action Pinning

The tj-actions/changed-files compromise worked because teams referenced mutable tags like @v35. Tag values can be silently rewritten. Full commit SHA pinning prevents this:

# Vulnerable
- uses: tj-actions/changed-files@v35

# Safe
- uses: tj-actions/changed-files@d6babd6899969df1a11d14c368283ea4436bca78

GitHub now offers organization-level policies to enforce SHA pinning and fail workflows that use unpinned actions. Check Settings → Actions → General at the organization level. Pinning alone isn’t sufficient—also consider a 7–14 day cooldown before adopting new action versions, since most supply-chain compromises are detected within a week.

Evaluate Self-Hosted Runner Exposure

Self-hosted runners are persistent by default. A compromised workflow can install backdoors that survive between jobs. The critical question is whether any public repository uses your self-hosted runners—if so, any contributor can submit a PR that executes arbitrary code on your infrastructure.

Check your runner groups under Settings → Actions → Runners and confirm that public repositories are excluded. For sensitive workloads, prefer just-in-time (JIT) runners that are destroyed after each job.

Replace Long-Lived Cloud Credentials with OIDC

If your workflows authenticate to AWS, Azure, or GCP using static credentials stored as secrets, those credentials are exposed to every action and step in that job. OpenID Connect (OIDC) eliminates this by issuing short-lived, job-scoped tokens at runtime. No secret to steal, no credential to rotate manually.

Additional Checks Worth Running

  • Artifact handling: Set persist-credentials: false on actions/checkout unless downstream steps explicitly need the token. This prevents the checkout token from remaining available in local Git configuration for later workflow steps.
  • Environment protections: Deployment secrets should live in GitHub Environments with required reviewers, not as plain repository secrets.
  • Artifact attestations: For published packages, GitHub’s artifact attestations provide a verifiable link between a build and its source workflow.
  • OpenSSF Scorecards: The Scorecards action can be configured to run automated checks for token permissions, pinned actions, and script injection—useful for catching regressions automatically.

Where to Start

Run a search across .github/workflows/ for these patterns: permissions blocks that are missing or set to write-all, ${{ inside run: steps, pull_request_target triggers, and action references without a full SHA. Those four checks will surface the highest-risk issues in most repositories without requiring any new tooling.

Conclusion

Securing GitHub Actions doesn’t require a full pipeline rewrite—most real-world incidents trace back to a small set of recurring mistakes: overly broad token scopes, unsafe interpolation of untrusted input, mutable action references, and runners exposed to untrusted code. Working through the checks above gives you a defensible baseline. Pair that baseline with automated scanning through OpenSSF Scorecards or similar tooling, and you’ll catch regressions before they reach production.

FAQs

GitHub's code search supports organization-wide queries. Search for terms like 'pull_request_target', 'permissions: write-all', or 'github.event.pull_request.title' scoped to path:.github/workflows. For deeper analysis, tools like Octoscan, zizmor, and the OpenSSF Scorecards action can scan entire repositories and report on token scopes, unpinned actions, and injection sinks automatically.

No, but it makes updates explicit. You decide when to bump the SHA after reviewing the diff between versions. Tools like Dependabot and Renovate can open pull requests that update pinned SHAs automatically, giving you the safety of pinning without the maintenance burden. A 7–14 day delay before merging these updates further reduces exposure to freshly compromised releases.

For AWS, Azure, GCP, and most major providers, yes. OIDC tokens are short-lived, scoped to a specific workflow run, and cannot be exfiltrated for later use. The setup requires configuring a trust relationship in your cloud provider, but eliminates the rotation burden and limits blast radius if a workflow is compromised. Static secrets remain a valid fallback only when OIDC is unsupported.

The pull_request trigger runs in the context of the fork and has no access to secrets from the base repository, making it safe for running tests on untrusted code. The pull_request_target trigger runs in the context of the base repository with access to secrets, intended for tasks like labeling PRs. Mixing pull_request_target with a checkout of the fork's code is the dangerous combination to avoid.

Gain control over your UX

See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay. — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay