GitHub Actions workflows automate builds, tests, deployments, releases, and many other development tasks. Because workflows can process pull request data, branch names, issue content, commit messages, and other external input, an unsafe workflow can unintentionally turn untrusted data into executable commands.
This class of vulnerability is commonly called workflow injection.
CodeQL provides static analysis capabilities that can help identify dangerous data flows in GitHub Actions workflows. Instead of checking only whether a particular string looks suspicious, CodeQL can reason about how data moves from a potentially untrusted source to a sensitive operation.
For development teams, building a dedicated CodeQL test suite around workflow injection can provide a repeatable way to detect insecure workflow patterns before they reach production.
What Is GitHub Actions Workflow Injection?
Workflow injection occurs when attacker-controlled input reaches a command or another security-sensitive operation without appropriate validation or isolation.
Consider this workflow:
name: Build
on:
issues:
types: [opened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Process issue
run: echo "${{ github.event.issue.title }}"
The problem is not simply the presence of an expression.
The concern is that data from an external event is being inserted directly into a shell command.
A safer design is to pass the value through an environment variable:
- name: Process issue
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
echo "$ISSUE_TITLE"
This separates the workflow expression from the shell command and reduces the risk of shell metacharacters being interpreted as executable syntax.
The exact security requirements depend on the shell, runner, event, and command being used.
Why CodeQL Is Useful Here
Traditional security testing might search workflow files for patterns such as:
${{ github.event.* }}
That can identify potentially risky expressions, but it can also produce many false positives.
CodeQL provides a different approach.
It can model:
Source
↓
Data flow
↓
Sink
For workflow injection, a source might represent untrusted GitHub event data, while a sink represents a command execution context.
This allows a query to focus on the flow of potentially dangerous data rather than only matching text.
GitHub's CodeQL documentation includes security analysis for GitHub Actions workflows and identifies several sources of untrusted input that can create script-injection risks.
Sources and Sinks
A useful way to understand workflow-injection analysis is through sources and sinks.
Source
A source is where potentially attacker-controlled data enters the workflow.
Examples can include:
Issue title
Issue body
Pull request title
Pull request body
Commit message
Branch name
Other event payload fields
Sink
A sink is an operation where the data can become executable or otherwise security-sensitive.
Examples include:
run:
Shell commands
Script execution
Command-line arguments
The security problem exists when:
Untrusted Source
↓
Unsafe Data Flow
↓
Command Execution Sink
CodeQL queries can model this relationship.
Building a Test Case
A good security test suite should contain intentionally vulnerable examples.
For example:
name: Vulnerable Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Print title
run: |
echo "${{ github.event.pull_request.title }}"
The purpose of this file is not to deploy it.
It belongs in a controlled security-test repository or test fixture so that the CodeQL query can verify that the vulnerability is detected.
The test suite should also contain a secure version:
name: Safer Workflow
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Print title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "$PR_TITLE"
Now the query can be tested against both vulnerable and safe cases.
Creating a CodeQL Query
CodeQL queries are written using the CodeQL query language.
A simplified query structure looks like:
import githubactions
from WorkflowRunStep step
where
step.getBody().regexpMatch(".*\\$\\{\\{.*\\}\\}.*")
select step,
"Potentially unsafe workflow expression in a shell command."
This example is intentionally simplified.
A production security query should model the actual source and sink relationships rather than relying on a single regular expression.
The GitHub Actions CodeQL libraries provide classes and predicates for analyzing workflow structures. The available API should be checked against the CodeQL version used by the repository.
Modeling a Security Finding
A useful custom query should provide a clear result.
For example:
select step,
"Untrusted workflow input reaches a shell command."
A developer seeing the result should understand:
What was detected?
Where was it detected?
Why could it be dangerous?
What should be reviewed?
A vague message such as:
"Security issue detected."
does not provide enough information to act on the result.
Testing the Query
CodeQL supports query testing using test databases and expected results.
A test directory might look like:
codeql/
github-actions/
workflow-injection.ql
workflow-injection.qlref
test/
vulnerable.yml
safe.yml
expected/
The exact structure depends on the CodeQL pack and testing workflow being used.
A useful test suite should contain several categories:
Direct interpolation
Indirect interpolation
Safe environment-variable usage
Quoted values
Different event types
Different shells
False-positive examples
The goal is to test both detection and non-detection.
Testing Direct Injection
For example:
run: |
echo "${{ github.event.issue.body }}"
should be considered a high-risk pattern when the event data can be controlled by an untrusted actor.
The corresponding secure pattern can be:
env:
BODY: ${{ github.event.issue.body }}
run: |
echo "$BODY"
The security test should confirm that the first example is reported and the second is not reported by the intended query.
Testing Pull Request Events Carefully
GitHub Actions event security depends heavily on the event type.
For example:
on:
pull_request:
and:
on:
pull_request_target:
have different security characteristics.
pull_request_target runs in the context of the base repository and therefore requires particular care when workflow code interacts with pull request data.
A workflow should not blindly execute untrusted code or interpolate untrusted pull request content into commands.
GitHub documents the security implications of untrusted input and recommends avoiding direct use of such data in scripts.
A More Realistic Vulnerable Example
Consider:
name: Label PR
on:
pull_request_target:
types: [opened]
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Process PR
run: |
echo "PR: ${{ github.event.pull_request.title }}"
./scripts/process.sh
The workflow may appear harmless because it only prints the title.
However, shell interpretation can turn attacker-controlled content into something other than ordinary text depending on how the value is embedded.
The safer pattern is:
- name: Process PR
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "PR: $PR_TITLE"
./scripts/process.sh
The environment-variable approach reduces the chance that shell syntax in the event value becomes part of the command itself.
Building a Regression Test Suite
Once the query identifies a vulnerability, preserve it as a regression test.
For example:
tests/
workflow-injection/
direct-expression.yml
multiline-expression.yml
issue-body.yml
pr-title.yml
safe-env-variable.yml
safe-static-command.yml
This gives the security team a reusable test corpus.
When the query changes:
Existing Tests
↓
Run CodeQL Tests
↓
Expected Results
↓
PASS / FAIL
A query modification should not silently reduce detection coverage.
Avoiding False Positives
Security queries need a balance between detection and precision.
Consider:
run: echo "${{ github.repository }}"
github.repository is not equivalent to arbitrary issue content.
A query that reports every ${{ }} expression will create unnecessary findings.
Instead, distinguish between:
Trusted workflow metadata
and:
Potentially attacker-controlled event data
The more precisely the query models sources and sinks, the more useful the resulting alerts become.
Using CodeQL in GitHub Actions
A repository can run CodeQL analysis through GitHub Actions.
A simplified workflow is:
name: CodeQL
on:
push:
pull_request:
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Analyze
uses: github/codeql-action/analyze@v3
The exact language configuration should match the CodeQL analysis being performed. GitHub Actions workflow analysis uses the appropriate CodeQL support for workflow files rather than treating YAML as an ordinary application language.
For custom queries, the query pack and configuration should explicitly include the custom query suite.
Security Testing in Pull Requests
A strong workflow runs the custom CodeQL query when security-sensitive workflow files change.
Conceptually:
Pull Request
|
v
Workflow Files Changed?
|
+-- No → Normal checks
|
+-- Yes
↓
CodeQL Analysis
↓
Security Queries
↓
Findings?
/ \
Yes No
| |
Review Continue
This provides early feedback before an insecure workflow reaches the default branch.
Common Mistakes
Scanning Only for ${{ }}
Expression syntax alone does not prove a vulnerability.
The source and execution context matter.
Ignoring Event Types
pull_request, pull_request_target, issues, and other events have different trust characteristics.
Treating Every Finding as Critical
Severity should reflect exploitability and impact.
Testing Only Vulnerable Examples
A query that reports every workflow as vulnerable is not useful.
Include safe examples to measure false positives.
Running Security Tests Only After Deployment
Workflow security should be validated before changes reach production repositories.
Giving Workflows Excessive Permissions
Even a vulnerable workflow has less impact when its GITHUB_TOKEN permissions are appropriately restricted.
GitHub recommends using the minimum permissions required by a workflow.
Troubleshooting CodeQL Query Tests
If a custom query does not produce the expected result, check the following.
Verify the Query Pack
Confirm that the custom query is included in the CodeQL configuration.
Verify the Test Fixture
Make sure the vulnerable workflow actually contains the source-to-sink pattern the query is designed to detect.
Check the Expected Results
An incorrectly written expected-results file can make a valid query appear to fail.
Test Safe Examples
If the query reports secure workflows, refine the source or sink model.
Run With the Same CodeQL Version
Differences between local and CI CodeQL versions can affect query behavior and available libraries.
Best Practices
Model data flow instead of relying only on text matching.
Include both vulnerable and secure workflow fixtures.
Test different GitHub event types.
Treat pull-request data as potentially untrusted when appropriate.
Keep workflow token permissions minimal.
Review custom query findings with security context.
Preserve discovered vulnerabilities as regression tests.
Run security analysis before merging workflow changes.
Keep CodeQL and query dependencies updated.
Document why each custom query exists and what security property it protects.
Advantages and Disadvantages
Advantages
Detects dangerous workflow patterns automatically.
Can reason about source-to-sink relationships.
Supports repeatable security regression testing.
Integrates with pull-request workflows.
Helps security teams standardize workflow analysis.
Can reduce manual review of large numbers of workflow files.
Disadvantages
Custom queries require CodeQL expertise.
Poorly designed queries can produce false positives.
Workflow security depends heavily on event and permission context.
Static analysis cannot prove that every runtime behavior is safe.
Query maintenance is required as GitHub Actions and CodeQL capabilities evolve.
Conclusion
GitHub Actions workflows are part of an application's security boundary. They can access source code, secrets, cloud credentials, deployment environments, and other sensitive resources, so workflow injection should be treated as a serious security concern.
CodeQL provides a useful way to automate the detection of dangerous data flows. A strong security test suite should not simply search for suspicious expressions. It should model potentially untrusted sources, identify sensitive execution sinks, test vulnerable and safe examples, and preserve discovered issues as regression tests.
The overall workflow is straightforward:
Workflow Change
↓
CodeQL Analysis
↓
Custom Security Queries
↓
Source-to-Sink Detection
↓
Security Finding
↓
Developer Review
For teams managing .NET applications through GitHub Actions, this adds an important layer of protection around the CI/CD system itself. Secure application code is not enough if the automation that builds and deploys it can be manipulated through untrusted workflow input.