Introduction
GitHub Actions has become an important part of modern software delivery. Build, test, deployment, release, infrastructure, and automation tasks can all be controlled through workflow files stored directly in a repository.
That flexibility is useful, but it also creates a security concern.
A workflow is code.
If an attacker can influence a workflow's inputs, expressions, environment variables, cache behavior, or checkout process, the workflow may become a path to execute unintended commands with permissions available to the job.
This is where CodeQL becomes particularly valuable.
CodeQL 2.26.3 improves several GitHub Actions security queries and introduces additional modeling for JavaScript and TypeScript applications. The release includes improvements around untrusted checkout detection, cache poisoning, environment-variable injection, workflow triggers, and output clobbering.
For development teams, the interesting question is not simply whether CodeQL finds vulnerabilities.
It is whether the updated analysis can help identify unsafe GitHub Actions patterns before they become a real CI/CD security problem.
Why GitHub Actions Workflows Need Security Analysis
A typical workflow might look harmless:
name: Build
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: dotnet build
- name: Test
run: dotnet testBut GitHub Actions workflows can also:
Read repository contents
Access environment variables
Use tokens
Download dependencies
Upload artifacts
Access caches
Run scripts
Trigger deployments
Interact with cloud services
Use third-party actions
The security question therefore becomes:
Who controls the input?
|
v
What does the workflow do with it?
|
v
What permissions does the job have?That is the foundation of workflow security.
What CodeQL Does
CodeQL analyzes source code and represents it as a queryable model.
Instead of checking only individual lines, it can reason about relationships such as:
Untrusted Input
|
v
Workflow Expression
|
v
Privileged Step
|
v
Potential Security RiskThis is particularly useful for GitHub Actions because the dangerous behavior may involve multiple parts of a workflow.
For example:
run: echo "${{ github.event.pull_request.title }}"The risk is not simply that the workflow contains a string.
The important question is whether attacker-controlled data can reach a command execution context.
CodeQL 2.26.3 and GitHub Actions
CodeQL 2.26.3 includes several GitHub Actions analysis improvements.
Among them:
Better recognition of untrusted data from
github.event.merge_groupImproved output-clobbering detection
Better cache-poisoning analysis
Improved untrusted-checkout alerts
More accurate environment-variable injection analysis
Better classification of workflow triggers
Improvements to how cache permissions are considered
These changes matter because CI/CD security problems are often dependent on context.
A query that understands the trigger, source of data, and permissions can produce more useful results than a simple pattern search.
Understanding Untrusted Data
One of the most important concepts in GitHub Actions security is trust level.
Not every event has the same security characteristics.
For example:
push
pull_request
merge_group
workflow_dispatch
schedulecan have different sources of input and different security implications.
A workflow should therefore ask:
Can an untrusted actor influence this value?before passing that value into a sensitive operation.
Example of Dangerous Command Construction
Consider:
- name: Print title
run: echo "${{ github.event.pull_request.title }}"The problem is that the pull request title is external input.
A safer approach is to pass the value through an environment variable:
- name: Print title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "$PR_TITLE"This does not automatically make every workflow safe, but it avoids directly embedding untrusted data into shell source.
The broader principle is:
Untrusted Data
|
v
Safe Data Boundary
|
v
Commandrather than:
Untrusted Data
|
v
Shell SourceWhy Context Matters
Suppose the same input is used in two workflows.
Workflow A:
permissions:
contents: readWorkflow B:
permissions:
contents: writeThe potential impact is different.
This is why CodeQL's GitHub Actions queries attempt to reason about the context in which a workflow operates.
CodeQL 2.26.3 specifically improves environment-variable injection analysis by requiring the untrusted source and privileged context to originate from the same trigger event.
That kind of contextual analysis can reduce misleading results.
Untrusted Checkout
Checkout behavior is another important security area.
A workflow may check out code associated with an external contribution and then execute scripts from that repository.
Conceptually:
External Contribution
|
v
Checkout Code
|
v
Run Repository Script
|
v
Potential Code ExecutionThis becomes particularly important when the workflow has elevated permissions or access to sensitive resources.
CodeQL 2.26.3 improves the actions/untrusted-checkout queries and changes the reported path so that alerts begin at expressions controlling the untrusted checkout.
That makes the alert more directly connected to the risky configuration.
Why Self-Hosted Runners Need Extra Attention
A self-hosted runner executes workflow jobs on infrastructure controlled by the organization.
That can provide access to:
Internal Network
Private Tools
Cached Credentials
Local Files
Cloud ConfigurationTherefore, allowing untrusted code to execute on a self-hosted runner can have a much larger impact than executing it on an isolated managed runner.
However, CodeQL 2.26.3 removed the codeql.actions.security.SelfHostedQuery module because runner labels cannot reliably distinguish self-hosted runners from managed runners. Custom CodeQL queries depending on that module need to be updated.
This is an important example of why teams maintaining custom queries should pay attention to CodeQL breaking changes.
Cache Poisoning
GitHub Actions caching is useful for improving build performance.
A .NET workflow might cache:
NuGet packages
Build dependencies
Tool downloadsBut caches can become a security concern when low-trust workflows can influence content that a higher-trust workflow later consumes.
Conceptually:
Low-Trust Workflow
|
v
Write Cache
|
v
Privileged Workflow
|
v
Restore Cache
|
v
Execute Cached ContentThis creates a potential trust-boundary problem.
CodeQL 2.26.3 improves several cache-poisoning queries and now accounts for read-only cache access on low-trust triggers operating in the default-branch scope. Results are retained for situations where GitHub permits writing to the relevant cache scope.
A .NET Example
Consider a .NET workflow:
name: Build
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-buildThis is relatively straightforward.
Now imagine a much more complicated workflow:
Pull Request
|
+--> Checkout
|
+--> Restore Cache
|
+--> Execute Script
|
+--> Generate Artifact
|
+--> Upload Artifact
|
+--> DeployEach additional capability increases the number of security relationships that need to be considered.
This is where static analysis becomes valuable.
Workflow Permissions Matter
One of the simplest improvements developers can make is limiting permissions.
Instead of relying on broad defaults:
permissions:
contents: writeuse the minimum required:
permissions:
contents: readIf a particular job needs additional access, grant it at the narrowest appropriate scope.
For example:
jobs:
build:
permissions:
contents: readand separately:
jobs:
release:
permissions:
contents: writeThis creates a smaller blast radius.
The principle is:
Minimum Permission
+
Minimum Scope
=
Smaller Attack SurfaceEnvironment Variable Injection
Environment variables are another area that deserves attention.
Consider:
- name: Run command
env:
USER_INPUT: ${{ github.event.issue.title }}
run: |
echo "$USER_INPUT"Passing untrusted data through an environment variable is generally easier to reason about than inserting it directly into shell syntax.
But developers should still be careful about how that value is subsequently used.
For example:
eval "$USER_INPUT"turns the value back into executable shell code.
The safer principle is:
Input
|
v
Data
|
v
Explicit Commandrather than:
Input
|
v
Dynamic CommandOutput Clobbering
GitHub Actions workflows can communicate values between steps and jobs.
That makes output handling another potential security boundary.
Imagine:
Untrusted Input
|
v
Step Output
|
v
Later Step
|
v
Sensitive OperationIf output is interpreted incorrectly, data can potentially influence subsequent workflow behavior.

Join the conversation! Your thoughts help the community grow.