Why GitHub Actions workflows need security scanning
Over the past few months, the GitHub Security Lab has secured more than 75 GitHub Actions workflows in open source projects and disclosed over 90 distinct vulnerabilities. The research produced new CodeQL packs for GitHub Actions, which are now available in public preview for code scanning. Since code scanning and Copilot Autofix are free for public repositories, any open source project can use these queries to detect and fix vulnerable workflows.
Common vulnerabilities in GitHub Actions — such as pwn requests, untrusted input flowing into shell commands, and over-broad permissions — remain widespread. The root cause is usually a lack of awareness about how workflow inputs, environment variables, and action outputs interact. The new CodeQL support aims to close that gap by making Actions a first-class language in CodeQL, adding taint tracking for workflow data, and providing basic Bash script analysis.
From a single query to full taint tracking
Previously, CodeQL had one query for code injection in GitHub Actions. It was part of the JavaScript QL pack, which forced users to enable JavaScript scanning even when their repositories contained no JavaScript. Its representation of the GitHub Actions syntax was also incomplete, making it difficult to express sophisticated attack patterns, and it lacked taint tracking entirely.
That old query could catch simple cases where a known user-controlled property such as github.event.pull_request.body was interpolated into a run script:

But more realistic attacks follow a multi-step path. Consider a workflow triggered by a workflow_run event with no branch filters. A first step downloads an artifact from the triggering workflow:

Subsequent steps unpack content from that artifact to files:

The final step then uses that artifact-provided value as a step output, which later flows unsafely into an interpolated run script:

Not all artifacts are untrusted, but in this specific configuration, the artifact comes from an untrusted triggering workflow and can plant files in the runner's workspace. The new analysis needs to model that from that download step onward, every workspace file is a potential source of untrusted data. Taint tracking is what lets CodeQL trace that data across multiple steps, up to a dangerous sink like a shell command, an action input, or an environment variable.
The key capability of the new CodeQL packs is tracking untrusted data through the parts of a workflow that were previously opaque: step outputs, environment variables, composite actions, and whole workflows that call each other. That is what turns the analysis from a simple pattern match into something that can flag subtle vulnerabilities in complex CI pipelines.
Parsing Bash: basic support for command scripts
Bash is one of the most common scripting languages inside GitHub Actions run blocks, so the new packs include basic Bash support. The parser does not yet build a full abstract syntax tree, but it understands enough structure — assignments, pipelines, and redirections — to catch data flows through scripts.
Take a real vulnerable workflow found in the Azure CLI repository. A pull request title is assigned to a TITLE environment variable. Several commands later, that value ends up in a message variable that is appended to the GITHUB_ENV file:


A carefully crafted, multiline pull request title becomes an injection of arbitrary environment variables into subsequent CI steps. That can lead to secret exfiltration if the attacker manages to override the behavior of a later step.
Built-in action and script models
GitHub's event context remains the most common source of untrusted data: properties such as github.event.issue.title or github.event.comment.body. But any third-party action that returns filenames, parses issue bodies, or passes input data to its outputs must also be treated as a source. The team analyzed thousands of popular actions and contributed models that are now embedded in the analysis:
- 62 sources of untrusted data
- 129 summaries — actions acting as taint steps that pass data from an input to an output or environment variable, such as
actions/github-script - 2199 sinks, including actions like
azure/cliandazure/powershellthat execute code from their inputs, equivalent to arunstep
New query set covers workflow attack classes
Where previous support had a single code-injection query, the new packs add 18 queries covering the broader threat model for GitHub Actions:
- Execution of Untrusted Code
- Execution of Untrusted Code (TOCTOU)
- Artifact Poisoning
- Code Injection
- Environment Variable Injection
- Path Injection
- Unpinned Action Tag
- Improper Access Control
- Excessive Secrets Exposure
- Secrets In Artifacts
- Expression is Always True
- Unmasked Secret Exposure
- Cache Poisoning (Code Injection, Direct Cache, Untrusted Code)
- Use of Known Vulnerable Actions
- Missing Action Permissions
- Argument Injection (Experimental)
- Code Execution on Self Hosted Runners (Experimental)
- Output Clobbering (Experimental)
Validation results
Over the past months, the queries were tested on thousands of open source projects to check accuracy and performance. Vulnerabilities were found and reported in repositories from Microsoft, Azure, GitHub, Eclipse, Jupyter, Adobe, AWS, Cloudflare, Discord, Hibernate, HuggingFace, and Apache, among many others. The full set of affected repositories with their star counts:
| Repository | Stars |
|---|---|
| ant-design/ant-design | 92,412 |
| Excalidraw/excalidraw | 84,021 |
| apache/superset | 62,589 |
| withastro/astro | 46,604 |
| Stirling-Tools/Stirling-PDF | 44,988 |
| geekan/MetaGPT | 44,901 |
| Kong/kong | 39,221 |
| LAION-AI/Open-Assistant | 37,045 |
| appsmithorg/appsmith | 34,352 |
| gradio-app/gradio | 33,709 |
| DIYgod/RSSHub | 33,432 |
| calcom/cal.com | 32,282 |
| milvus-io/milvus | 30,299 |
| k3s-io/k3s | 28,010 |
| discordjs/discord.js | 25,390 |
| element-plus/element-plus | 24,488 |
| cilium/cilium | 20,150 |
| monkeytypegame/monkeytype | 15,635 |
| amplication/amplication | 15,196 |
| docker-mailserver/docker-mailserver | 14,643 |
| jupyterlab/jupyterlab | 14,167 |
| openimsdk/open-im-server | 14,041 |
| quarkusio/quarkus | 13,771 |
| espressif/arduino-esp32 | 13,609 |
| sympy/sympy | 12,967 |
| ionic-team/stencil | 12,561 |
| zephyrproject-rtos/zephyr | 10,819 |
| qgis/QGIS | 10,569 |
| trinodb/trino | 10,413 |
| OpenFeign/feign | 9,490 |
| marimo-team/marimo | 7,583 |
| dream-num/univer | 7,021 |
| aws/karpenter-provider-aws | 6,782 |
| hibernate/hibernate-orm | 5,976 |
| ant-design-blazor/ant-design-blazor | 5,809 |
| litestar-org/litestar | 5,511 |
The breadth of affected projects illustrates the reach of a potential supply chain attack: a single compromised workflow in a popular repository could cascade downstream into every project that consumes its releases, artifacts, or actions.
The danger zones in GitHub Actions triggers
After triaging and reporting many alerts, we've noticed recurring vulnerability patterns in GitHub Actions workflows. These patterns often trace back to a small set of trigger events and the common mistakes made around them.
pull_request_target: powerful but easy to abuse
The pull_request_target trigger runs workflows in the context of the pull request's base branch, which grants it privileges that pull_request does not have. Workflows activated by this event from a fork can read repository and organization secrets, hold write permissions, and avoid the usual approval safeguards. They also run in the context of the default branch, which can allow a malicious actor to poison the action cache and move laterally to more privileged workflows — even if the vulnerable workflow itself has permissions: {} set.
Several specific risks follow from this:
- Code execution from untrusted sources: Using
actions/checkoutwith a reference to the pull request's head branch can inadvertently execute malicious code submitted in a fork. That code runs in the target repository's environment, potentially exfiltrating secrets or tampering with contents and releases. - TOCTOU attacks: Even with approval gates like required labels, an attacker can submit a harmless pull request, wait for approval, then update the branch with malicious code before the workflow runs. The root cause is often confusion between
head.ref, which is a mutable branch reference an attacker can change, andhead.sha, an immutable commit pointer that should be used for checkout. - Non-default branch threats: Vulnerable versions of workflows can persist in non-default branches. An attacker can submit pull requests targeting those branches to exploit the older, unpatched workflow code.
- Cache poisoning: Removing write permissions does not prevent a workflow from poisoning the cache. A workflow running untrusted code can inject malicious content into cache entries, and other workflows that later use those entries may execute it.
If pull_request_target is truly needed, hardening options include:
- Repository checks: Restrict execution to pull requests from the base repository with a condition like
github.event.pull_request.head.repo.owner.login == 'myorg'. This blocks external forks entirely. - Actor checks: Only permit trusted actors, such as organization members or approved collaborators, to trigger the workflow. Avoid hardcoded username lists — a user can lose permissions over time, or a username may be abandoned and claimed by an attacker.
- Workflow splitting: For cases where you must accept forks or arbitrary users, split the work. An unprivileged workflow triggered by
pull_requesthandles the untrusted input without secrets or write access. A privileged workflow is then triggered byworkflow_runonly after the unprivileged one finishes its checks. Everything passed between them must be treated as untrusted.
workflow_run and its security boundary
The workflow_run trigger fires when another workflow completes, and it may grant write permissions and secrets that the triggering workflow never had. That privilege gap is exactly what attackers try to exploit.
A common misconception is that if the triggering workflow only runs on, say, a release event, an attacker can't initiate it. In fact, an attacker can submit a pull request that modifies the triggering workflow itself, including replacing its trigger events. Since pull_request workflows run in the pull request's HEAD branch, the modified workflow will execute. When it completes, it can trigger the existing workflow_run workflow, which then runs with elevated privileges — even though the initiating workflow had none.
Artifact poisoning is another vector. Workflows generate artifacts that can be shared with other runs, and an attacker can upload malicious content through a pull request. If the workflow_run workflow downloads an artifact without inspecting its contents, it can quietly execute that malicious payload in a privileged context.
Effective mitigations:
- Branch filters: Specify which branches can trigger the
workflow_runworkflow. This prevents triggers from fork branches. - Event origin checks: Validate that the triggering workflow did not come from a pull request, for instance by checking
github.event_name != 'pull_request'. - Treat artifacts as untrusted: Unzip downloaded artifacts to a temporary directory like
/tmpto avoid file overwrites in the workspace. - Avoid environment variables from artifacts: These can be injection points if an attacker controls the artifact contents.
- Validate output variables: If you define output variables based on artifact content, check the contents strictly — for example, that a value is really a number, not a string — before using them downstream.
Ineffective mitigation:
- Repository checks: Checking the repository owner, like
github.repository_owner == 'myorg', does not protect aworkflow_runworkflow. The workflow always runs in the context of the organization's default branch, so this check never fails. This pattern was observed in vulnerabilities in AWS Karpenter Provider and Cloudflare Workers SDK.
issue_comment and the IssueOops pitfalls
Workflows triggered by issue_comment automate tasks based on comments on issues or pull requests — the foundation of IssueOps. This convenience creates real security risks.
Two primary dangers stand out:
- TOCTOU vulnerabilities: If the workflow checks out code from a pull request after an issue comment, there's a race. An attacker can submit a benign pull request, wait for an administrator to approve it via comment, then push a malicious commit before the workflow executes. The workflow then runs the wrong code.
- Bypassing approval mechanisms:
issue_commentis not subject to the approval requirements applied topull_requestworkflows. Simply commenting on a pull request can trigger the workflow, potentially executing unvetted code.
Recommended mitigation:
- Label gates instead of comments: Trigger critical workflows with labels rather than comments. The labeled activity type for a
pull_requesttrigger includes the pull request's latest commit SHA, so the workflow doesn't need to resolve the pull request to a commit reference. That closes the window for an attacker to modify the pull request. When checking out, use the commit SHA rather than the HEAD reference to avoid TOCTOU issues.
Ineffective or incomplete mitigations:
- Actor checks: Checking only the commenter's identity doesn't help if the commenter is an innocent admin and the pull request author is the attacker. The actor triggering the workflow and the actor trying to exploit it may be different people.
- Date checks: Comparing commit timestamps against the comment timestamp cannot reliably detect tampering. GitHub does not expose a trustworthy "push date," and attackers can forge commit dates.
- Repository checks: Verifying the origin repository is redundant here. The
issue_commentevent always runs in the context of the target repository's default branch.
Getting started with Actions scanning
CodeQL support for GitHub Actions is now in public preview. The new QL packs let you scan repositories for common vulnerability patterns in workflow files, which can help reduce the risk of supply chain attacks on open source software.
How you enable the scanner depends on your current CodeQL setup:
- New default setup: If your repository is configured for default setup for the first time, Code scanning will automatically try to analyze Actions whenever workflow files exist on the default branch. No extra configuration is needed—just review and fix any alerts that appear.
- Existing default setup: If your repository already uses default setup, open the Default Setup settings and explicitly turn on Actions analysis.
- Advanced setup: If you use advanced setup, simply add
actionsto your language matrix.
Once enabled, the scans will flag vulnerable patterns in your workflow definitions, giving you a chance to remediate them before they become an entry point for attackers.



