Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions actions/ql/lib/codeql/actions/dataflow/FlowSources.qll
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ private import codeql.actions.security.ArtifactPoisoningQuery
private import codeql.actions.security.UntrustedCheckoutQuery
private import codeql.actions.config.Config
private import codeql.actions.dataflow.ExternalFlow
private import codeql.actions.ast.internal.Ast
private import codeql.actions.ast.internal.Yaml

/**
* A data flow source.
Expand Down Expand Up @@ -363,3 +365,68 @@ class OctokitRequestActionSource extends RemoteFlowSource {

override string getEventName() { result = this.asExpr().getATriggerEvent().getName() }
}

/**
* A workflow_dispatch input of type string used in an expression.
* Inputs with type string (or no explicit type, which defaults to string)
* are considered untrusted since they can be controlled by any user
* who can trigger the workflow (write access to the repository)
*/
class WorkflowDispatchInputSource extends RemoteFlowSource {
WorkflowDispatchInputSource() {
exists(InputsExpression e, Event event, string inputName |
this.asExpr() = e and
inputName = e.getFieldName() and
event = e.getATriggerEvent() and
event.getName() = "workflow_dispatch" and
exists(YamlMapping eventMapping, YamlMapping inputsMapping |
eventMapping = event.(EventImpl).getValueNode() and
inputsMapping = eventMapping.lookup("inputs") and
exists(YamlMapping inputMapping |
inputMapping = inputsMapping.lookup(inputName) and
(
// explicit type: string
inputMapping.lookup("type").(YamlScalar).getValue().toLowerCase() = "string"
or
// no explicit type (defaults to string in GitHub Actions)
not exists(inputMapping.lookup("type"))
)
)
)
)
}

override string getSourceType() { result = "text" }

override string getEventName() { result = "workflow_dispatch" }
}

/**
* A workflow_call input of type string used in an expression.
* Only string-typed inputs are considered vulnerable to code injection.
* This is lower risk since only workflow authors control the callers,
* but the inputs may still originate from untrusted user input in the
* calling workflow.
*/
class WorkflowCallInputSource extends RemoteFlowSource {
WorkflowCallInputSource() {
exists(InputsExpression e, ReusableWorkflowImpl w, string inputName |
this.asExpr() = e and
inputName = e.getFieldName() and
w = e.getEnclosingWorkflow() and
exists(YamlMapping inputsMapping, YamlMapping inputMapping |
inputsMapping = w.getInputs().getNode() and
inputMapping = inputsMapping.lookup(inputName) and
inputMapping.lookup("type").(YamlScalar).getValue().toLowerCase() = "string"
)
)
}

override string getSourceType() { result = "text" }

override string getEventName() {
result = this.asExpr().getATriggerEvent().getName()
or
not exists(this.asExpr().getATriggerEvent()) and result = "workflow_call"
}
}
33 changes: 33 additions & 0 deletions actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ predicate criticalSeverityCodeInjection(
source.getNode().(RemoteFlowSource).getEventName() = event.getName()
}

/**
* Holds if the source is an input expression inside a reusable workflow (workflow_call).
*/
private predicate isWorkflowCallInput(DataFlow::Node source) {
exists(InputsExpression e |
source.asExpr() = e and
e.getEnclosingWorkflow() instanceof ReusableWorkflow
)
}

/**
* Holds if the source is an input expression that can be supplied via workflow_dispatch.
*/
private predicate isWorkflowDispatchInput(DataFlow::Node source) {
source instanceof WorkflowDispatchInputSource
}

/**
* Holds if there is a code injection flow from `source` to `sink` with medium severity.
*/
Expand All @@ -107,6 +124,22 @@ predicate mediumSeverityCodeInjection(
) {
CodeInjectionFlow::flowPath(source, sink) and
not criticalSeverityCodeInjection(source, sink, _) and
not (isWorkflowCallInput(source.getNode()) and not isWorkflowDispatchInput(source.getNode())) and
not isGithubScriptUsingToJson(sink.getNode().asExpr())
}

/**
* Holds if there is a code injection flow from `source` to `sink` with low severity.
* This covers workflow_call inputs, which are lower risk since only users with
* write access controls the callers, but the inputs may still originate from
* untrusted user input in the calling workflow.
*/
predicate lowSeverityCodeInjection(
CodeInjectionFlow::PathNode source, CodeInjectionFlow::PathNode sink
) {
CodeInjectionFlow::flowPath(source, sink) and
source.getNode() instanceof WorkflowCallInputSource and
not criticalSeverityCodeInjection(source, sink, _) and
not isGithubScriptUsingToJson(sink.getNode().asExpr())
}

Expand Down
66 changes: 66 additions & 0 deletions actions/ql/src/Security/CWE-094/CodeInjectionLow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Overview

Using string-typed `workflow_call` inputs in GitHub Actions may lead to code injection in contexts like _run:_ or _script:_.

Inputs declared as `string` should be treated with caution. Although `workflow_call` can only be triggered by other workflows (not directly by external users), the calling workflow may pass untrusted user input as arguments. Since the reusable workflow author has no control over the callers, these inputs may still originate from untrusted data.

Comment on lines +1 to +6
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new query (actions/code-injection/low) and changes the results/metadata of code injection analysis; per the repository’s change-notes guidance, this should come with an unreleased change note under actions/ql/src/change-notes/ (for example with category newQuery / minorAnalysis).

Copilot uses AI. Check for mistakes.
Code injection in GitHub Actions may allow an attacker to exfiltrate any secrets used in the workflow and the temporary GitHub repository authorization token.

## Recommendation

The best practice to avoid code injection vulnerabilities in GitHub workflows is to set the untrusted input value of the expression to an intermediate environment variable and then use the environment variable using the native syntax of the shell/script interpreter (that is, not _${{ env.VAR }}_).

It is also recommended to limit the permissions of any tokens used by a workflow such as the GITHUB_TOKEN.

## Example

### Incorrect Usage

The following example uses a `workflow_call` input directly in a _run:_ step, which allows code injection if the caller passes untrusted data:

```yaml
on:
workflow_call:
inputs:
title:
description: 'Title'
type: string

jobs:
greet:
runs-on: ubuntu-latest
steps:
- run: |
echo '${{ inputs.title }}'
```

### Correct Usage

The following example safely uses a `workflow_call` input by passing it through an environment variable:

```yaml
on:
workflow_call:
inputs:
title:
description: 'Title'
type: string

jobs:
greet:
runs-on: ubuntu-latest
steps:
- env:
TITLE: ${{ inputs.title }}
run: |
echo "$TITLE"
```

## References

- GitHub Security Lab Research: [Keeping your GitHub Actions and workflows secure: Untrusted input](https://securitylab.github.com/research/github-actions-untrusted-input).
- GitHub Docs: [Security hardening for GitHub Actions](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions).
- GitHub Docs: [Reusing workflows](https://docs.github.com/en/actions/using-workflows/reusing-workflows).
- Common Weakness Enumeration: [CWE-94: Improper Control of Generation of Code ('Code Injection')](https://cwe.mitre.org/data/definitions/94.html).
- Common Weakness Enumeration: [CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')](https://cwe.mitre.org/data/definitions/95.html).
- Common Weakness Enumeration: [CWE-116: Improper Encoding or Escaping of Output](https://cwe.mitre.org/data/definitions/116.html).
25 changes: 25 additions & 0 deletions actions/ql/src/Security/CWE-094/CodeInjectionLow.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @name Code injection
* @description Using unsanitized workflow_call inputs as code allows a calling workflow to
* pass untrusted user input, leading to code execution.
* @kind path-problem
* @problem.severity warning
* @security-severity 3.0
* @precision low
* @id actions/code-injection/low
* @tags actions
* security
* external/cwe/cwe-094
* external/cwe/cwe-095
* external/cwe/cwe-116
*/

import actions
import codeql.actions.security.CodeInjectionQuery
import CodeInjectionFlow::PathGraph

from CodeInjectionFlow::PathNode source, CodeInjectionFlow::PathNode sink
where lowSeverityCodeInjection(source, sink)
select sink.getNode(), source, sink,
"Potential code injection in $@, which may be controlled by a calling workflow.", sink,
sink.getNode().asExpr().(Expression).getRawExpression()
40 changes: 40 additions & 0 deletions actions/ql/src/Security/CWE-094/CodeInjectionMedium.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Using user-controlled input in GitHub Actions may lead to code injection in contexts like _run:_ or _script:_.

This includes inputs from events such as `issue_comment`, `pull_request`, and `workflow_dispatch`. In particular, `workflow_dispatch` inputs with type `string` (or no type, which defaults to `string`) are user-controlled and should be treated as untrusted. Note that `workflow_dispatch` can only be triggered by users with write permissions to the repository, so the risk is lower than for events like `issue_comment` which can be triggered by any user. Nevertheless, this is still a code injection vector and should be handled safely.

Code injection in GitHub Actions may allow an attacker to exfiltrate any secrets used in the workflow and the temporary GitHub repository authorization token. The token may have write access to the repository, allowing an attacker to make changes to the repository.

## Recommendation
Expand All @@ -27,6 +29,24 @@ jobs:
echo '${{ github.event.comment.body }}'
```

The following example uses a `workflow_dispatch` string input directly in a _run:_ step, which allows code injection:

```yaml
on:
workflow_dispatch:
inputs:
title:
description: 'Title'
type: string

jobs:
greet:
runs-on: ubuntu-latest
steps:
- run: |
echo '${{ inputs.title }}'
```

The following example uses an environment variable, but **still allows the injection** because of the use of expression syntax:

```yaml
Expand Down Expand Up @@ -57,6 +77,26 @@ jobs:
echo "$BODY"
```

The following example safely uses a `workflow_dispatch` input by passing it through an environment variable:

```yaml
on:
workflow_dispatch:
inputs:
title:
description: 'Title'
type: string

jobs:
greet:
runs-on: ubuntu-latest
steps:
- env:
TITLE: ${{ inputs.title }}
run: |
echo "$TITLE"
```

The following example uses `process.env` to read environment variables within JavaScript code.

```yaml
Expand Down
3 changes: 3 additions & 0 deletions actions/ql/src/codeql-suites/actions-security-extended.qls
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
- queries: .
- apply: security-extended-selectors.yml
from: codeql/suite-helpers
# Explicitly include low-precision queries that are excluded by security-extended-selectors.yml.
- include:
query path: Security/CWE-094/CodeInjectionLow.ql
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
edges
| .github/workflows/reusable_workflow.yml:22:7:24:4 | Job outputs node [job-output1] | .github/workflows/reusable_workflow.yml:11:17:11:52 | jobs.job1.outputs.job-output1 | provenance | |
| .github/workflows/reusable_workflow.yml:22:7:24:4 | Job outputs node [job-output2] | .github/workflows/reusable_workflow.yml:13:17:13:52 | jobs.job1.outputs.job-output2 | provenance | |
| .github/workflows/reusable_workflow.yml:22:21:22:57 | steps.step1.outputs.step-output | .github/workflows/reusable_workflow.yml:22:7:24:4 | Job outputs node [job-output1] | provenance | |
| .github/workflows/reusable_workflow.yml:23:21:23:63 | steps.step2.outputs.all_changed_files | .github/workflows/reusable_workflow.yml:22:7:24:4 | Job outputs node [job-output2] | provenance | |
| .github/workflows/reusable_workflow.yml:25:9:31:6 | Run Step: step1 [step-output] | .github/workflows/reusable_workflow.yml:22:21:22:57 | steps.step1.outputs.step-output | provenance | |
| .github/workflows/reusable_workflow.yml:27:25:27:49 | inputs.config-path | .github/workflows/reusable_workflow.yml:25:9:31:6 | Run Step: step1 [step-output] | provenance | |
| .github/workflows/reusable_workflow.yml:31:9:33:43 | Uses Step: step2 | .github/workflows/reusable_workflow.yml:23:21:23:63 | steps.step2.outputs.all_changed_files | provenance | |
nodes
| .github/workflows/reusable_workflow.yml:11:17:11:52 | jobs.job1.outputs.job-output1 | semmle.label | jobs.job1.outputs.job-output1 |
| .github/workflows/reusable_workflow.yml:13:17:13:52 | jobs.job1.outputs.job-output2 | semmle.label | jobs.job1.outputs.job-output2 |
| .github/workflows/reusable_workflow.yml:22:7:24:4 | Job outputs node [job-output1] | semmle.label | Job outputs node [job-output1] |
| .github/workflows/reusable_workflow.yml:22:7:24:4 | Job outputs node [job-output2] | semmle.label | Job outputs node [job-output2] |
| .github/workflows/reusable_workflow.yml:22:21:22:57 | steps.step1.outputs.step-output | semmle.label | steps.step1.outputs.step-output |
| .github/workflows/reusable_workflow.yml:23:21:23:63 | steps.step2.outputs.all_changed_files | semmle.label | steps.step2.outputs.all_changed_files |
| .github/workflows/reusable_workflow.yml:25:9:31:6 | Run Step: step1 [step-output] | semmle.label | Run Step: step1 [step-output] |
| .github/workflows/reusable_workflow.yml:27:25:27:49 | inputs.config-path | semmle.label | inputs.config-path |
| .github/workflows/reusable_workflow.yml:31:9:33:43 | Uses Step: step2 | semmle.label | Uses Step: step2 |
subpaths
#select
| .github/workflows/reusable_workflow.yml:11:17:11:52 | jobs.job1.outputs.job-output1 | .github/workflows/reusable_workflow.yml:27:25:27:49 | inputs.config-path | .github/workflows/reusable_workflow.yml:11:17:11:52 | jobs.job1.outputs.job-output1 | Source |
| .github/workflows/reusable_workflow.yml:13:17:13:52 | jobs.job1.outputs.job-output2 | .github/workflows/reusable_workflow.yml:31:9:33:43 | Uses Step: step2 | .github/workflows/reusable_workflow.yml:13:17:13:52 | jobs.job1.outputs.job-output2 | Source |
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Code Injection via workflow_call

on:
workflow_call:
inputs:
title:
description: "Title string input"
required: true
type: string
count:
description: "A number input"
required: false
type: number
flag:
description: "A boolean input"
required: false
type: boolean

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest

steps:
# Vulnerable: string input used directly in run script
- name: vulnerable string input
run: |
echo "${{ inputs.title }}"

# Not vulnerable: number input constrained by GitHub to numeric values
- name: safe number input
run: |
echo "${{ inputs.count }}"

# Not vulnerable: boolean input constrained by GitHub to true/false values
- name: safe boolean input
run: |
echo "${{ inputs.flag }}"

# Not vulnerable: input passed safely through env var
- name: safe string input via env
run: |
echo "$title"
env:
title: ${{ inputs.title }}
Loading
Loading