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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: deprecated
---
* The class `StepsExpression` has been deprecated. Use the new class `StepOutputExpression` instead, which has the same functionality.
7 changes: 6 additions & 1 deletion actions/ql/lib/codeql/actions/Ast.qll
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,12 @@ class GitHubExpression extends SimpleReferenceExpression instanceof GitHubExpres

class SecretsExpression extends SimpleReferenceExpression instanceof SecretsExpressionImpl { }

class StepsExpression extends SimpleReferenceExpression instanceof StepsExpressionImpl {
/**
* DEPRECATED: Use `StepOutputExpression` instead.
*/
deprecated class StepsExpression = StepOutputExpression;

class StepOutputExpression extends SimpleReferenceExpression instanceof StepsExpressionImpl {
string getStepId() { result = super.getStepId() }
}

Expand Down
12 changes: 6 additions & 6 deletions actions/ql/lib/codeql/actions/dataflow/TaintSteps.qll
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ predicate fileDownloadToRunStep(DataFlow::Node pred, DataFlow::Node succ) {
* A read of the _files field of the dorny/paths-filter action.
*/
predicate dornyPathsFilterTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(StepsExpression o |
exists(StepOutputExpression o |
pred instanceof DornyPathsFilterSource and
o.getStepId() = pred.asExpr().(UsesStep).getId() and
o.getFieldName().matches("%_files") and
Expand All @@ -49,7 +49,7 @@ predicate dornyPathsFilterTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
* A read of user-controlled field of the tj-actions/changed-files action.
*/
predicate tjActionsChangedFilesTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(StepsExpression o |
exists(StepOutputExpression o |
pred instanceof TJActionsChangedFilesSource and
o.getTarget() = pred.asExpr() and
o.getStepId() = pred.asExpr().(UsesStep).getId() and
Expand All @@ -69,7 +69,7 @@ predicate tjActionsChangedFilesTaintStep(DataFlow::Node pred, DataFlow::Node suc
* A read of user-controlled field of the tj-actions/verify-changed-files action.
*/
predicate tjActionsVerifyChangedFilesTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(StepsExpression o |
exists(StepOutputExpression o |
pred instanceof TJActionsVerifyChangedFilesSource and
o.getTarget() = pred.asExpr() and
o.getStepId() = pred.asExpr().(UsesStep).getId() and
Expand All @@ -82,7 +82,7 @@ predicate tjActionsVerifyChangedFilesTaintStep(DataFlow::Node pred, DataFlow::No
* A read of user-controlled field of the xt0rted/slash-command-action action.
*/
predicate xt0rtedSlashCommandActionTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(StepsExpression o |
exists(StepOutputExpression o |
pred instanceof Xt0rtedSlashCommandSource and
o.getTarget() = pred.asExpr() and
o.getStepId() = pred.asExpr().(UsesStep).getId() and
Expand All @@ -95,7 +95,7 @@ predicate xt0rtedSlashCommandActionTaintStep(DataFlow::Node pred, DataFlow::Node
* A read of user-controlled field of the zentered/issue-forms-body-parser action.
*/
predicate zenteredIssueFormBodyParserSource(DataFlow::Node pred, DataFlow::Node succ) {
exists(StepsExpression o |
exists(StepOutputExpression o |
pred instanceof ZenteredIssueFormBodyParserSource and
o.getTarget() = pred.asExpr() and
o.getStepId() = pred.asExpr().(UsesStep).getId() and
Expand All @@ -114,7 +114,7 @@ predicate zenteredIssueFormBodyParserSource(DataFlow::Node pred, DataFlow::Node
* A read of user-controlled field of the octokit/request-action action.
*/
predicate octokitRequestActionTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(StepsExpression o |
exists(StepOutputExpression o |
pred instanceof OctokitRequestActionSource and
o.getTarget() = pred.asExpr() and
o.getStepId() = pred.asExpr().(UsesStep).getId() and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ newtype TContent =
TFieldContent(string name) {
// We only use field flow for env, steps and jobs outputs
// not for accessing other context fields such as matrix or inputs
name = any(StepsExpression a).getFieldName() or
name = any(StepOutputExpression a).getFieldName() or
name = any(NeedsExpression a).getFieldName() or
name = any(JobsExpression a).getFieldName() or
name = any(EnvExpression a).getFieldName()
Expand Down Expand Up @@ -205,7 +205,7 @@ predicate parameterMatch(ParameterPosition ppos, ArgumentPosition apos) { ppos =
* field name.
*/
predicate stepsCtxLocalStep(Node nodeFrom, Node nodeTo) {
exists(Uses astFrom, StepsExpression astTo |
exists(Uses astFrom, StepOutputExpression astTo |
madSource(nodeFrom, _, "output." + ["*", astTo.getFieldName()]) and
astFrom = nodeFrom.asExpr() and
astTo = nodeTo.asExpr() and
Expand Down Expand Up @@ -310,7 +310,7 @@ predicate ctxFieldReadStep(Node node1, Node node2, ContentSet c) {
exists(SimpleReferenceExpression access |
(
access instanceof NeedsExpression or
access instanceof StepsExpression or
access instanceof StepOutputExpression or
access instanceof JobsExpression or
access instanceof EnvExpression
) and
Expand Down
98 changes: 85 additions & 13 deletions actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ Event getRelevantCachePoisoningEventForSink(DataFlow::Node sink) {
)
}

private predicate codeInjectionAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(Uses step |
pred instanceof FileSource and
pred.asExpr().(Step).getAFollowingStep() = step and
succ.asExpr() = step and
madSink(succ, "code-injection")
)
or
exists(Run run |
pred instanceof FileSource and
pred.asExpr().(Step).getAFollowingStep() = run and
succ.asExpr() = run.getScript() and
exists(run.getScript().getAFileReadCommand())
)
}

/**
* A taint-tracking configuration for unsafe user input
* that is used to construct and evaluate a code script.
Expand All @@ -58,19 +74,7 @@ private module CodeInjectionConfig implements DataFlow::ConfigSig {
predicate isSink(DataFlow::Node sink) { sink instanceof CodeInjectionSink }

predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(Uses step |
pred instanceof FileSource and
pred.asExpr().(Step).getAFollowingStep() = step and
succ.asExpr() = step and
madSink(succ, "code-injection")
)
or
exists(Run run |
pred instanceof FileSource and
pred.asExpr().(Step).getAFollowingStep() = run and
succ.asExpr() = run.getScript() and
exists(run.getScript().getAFileReadCommand())
)
codeInjectionAdditionalFlowStep(pred, succ)
}

predicate observeDiffInformedIncrementalMode() { any() }
Expand All @@ -87,6 +91,64 @@ private module CodeInjectionConfig implements DataFlow::ConfigSig {
/** Tracks flow of unsafe user input that is used to construct and evaluate a code script. */
module CodeInjectionFlow = TaintTracking::Global<CodeInjectionConfig>;

private predicate knownSafeAction(string action) {
action =
[
// Setup actions - version/cache outputs are deterministic
"actions/setup-java",
"actions/setup-python",
"actions/setup-node",
"actions/setup-go",
"actions/setup-dotnet",
"actions/cache",
"actions/download-artifact",
"actions/configure-pages",
"actions/attest-build-provenance",
"actions/create-github-app-token",
"oracle-actions/setup-java",
"spring-io/artifactory-deploy-action",
"YunaBraska/java-info-action",
// Docker actions - digest/version outputs are system-generated
"docker/build-push-action",
"docker/metadata-action",
"docker/setup-buildx-action",
// PR/repo automation - outputs are GitHub-assigned identifiers
"dorny/test-reporter",
"peter-evans/create-pull-request",
// AWS actions - outputs are AWS-generated identifiers
"aws-actions/aws-codebuild-run-build",
// Security/crypto actions - outputs are cryptographic, not user-controllable
"crazy-max/ghaction-import-gpg",
// Hardware/system info actions - outputs are deterministic
"SimonShi1994/cpu-cores"
]
}

/**
* A taint-tracking configuration for step outputs
* that are used to construct and evaluate a code script.
*/
private module CodeInjectionFromStepOutputConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
exists(StepOutputExpression soe, UsesStep us |
soe = source.asExpr() and soe.getStepId() = us.getId()
|
not knownSafeAction(us.getCallee())
)
}

predicate isSink(DataFlow::Node sink) { sink instanceof CodeInjectionSink }

predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) {
codeInjectionAdditionalFlowStep(pred, succ)
}

predicate observeDiffInformedIncrementalMode() { any() }
}

/** Tracks flow of unsafe user input that is used to construct and evaluate a code script. */
module CodeInjectionFromStepOutputFlow = TaintTracking::Global<CodeInjectionFromStepOutputConfig>;

/**
* Holds if there is a code injection flow from `source` to `sink` with
* critical severity, linked by `event`.
Expand All @@ -110,6 +172,16 @@ predicate mediumSeverityCodeInjection(
not isGithubScriptUsingToJson(sink.getNode().asExpr())
}

/**
* Holds if there is a code injection flow from `source` to `sink` with low severity.
*/
predicate lowSeverityCodeInjection(
CodeInjectionFromStepOutputFlow::PathNode source, CodeInjectionFromStepOutputFlow::PathNode sink
) {
CodeInjectionFromStepOutputFlow::flowPath(source, sink) and
not isGithubScriptUsingToJson(sink.getNode().asExpr())
}

/**
* Holds if `expr` is the `script` input to `actions/github-script` and it uses
* `toJson`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ private module ActionsMutableRefCheckoutConfig implements DataFlow::ConfigSig {
)
or
// 3rd party actions returning the PR head ref
exists(StepsExpression e, UsesStep step |
exists(StepOutputExpression e, UsesStep step |
source.asExpr() = e and
e.getStepId() = step.getId() and
(
Expand Down Expand Up @@ -86,7 +86,7 @@ private module ActionsSHACheckoutConfig implements DataFlow::ConfigSig {
)
or
// 3rd party actions returning the PR head sha
exists(StepsExpression e, UsesStep step |
exists(StepOutputExpression e, UsesStep step |
source.asExpr() = e and
e.getStepId() = step.getId() and
(
Expand Down Expand Up @@ -243,7 +243,7 @@ class ActionsMutableRefCheckout extends MutableRefCheckoutStep instanceof UsesSt
exists(string value, Expression expr |
value.regexpMatch(".*(head|branch|ref).*") and expr = this.getArgumentExpr("ref")
|
expr.(StepsExpression).getStepId() = value
expr.(StepOutputExpression).getStepId() = value
or
expr.(SimpleReferenceExpression).getFieldName() = value and
not expr instanceof GitHubExpression
Expand Down Expand Up @@ -278,7 +278,7 @@ class ActionsSHACheckout extends SHACheckoutStep instanceof UsesStep {
exists(string value, Expression expr |
value.regexpMatch(".*(head|sha|commit).*") and expr = this.getArgumentExpr("ref")
|
expr.(StepsExpression).getStepId() = value
expr.(StepOutputExpression).getStepId() = value
or
expr.(SimpleReferenceExpression).getFieldName() = value and
not expr instanceof GitHubExpression
Expand Down
91 changes: 91 additions & 0 deletions actions/ql/src/Security/CWE-094/CodeInjectionLow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
## Overview

Using the output of a previous workflow step in GitHub Actions may lead to code injection in contexts like _run:_ or _script:_ if the step output can be controlled by a malicious actor. This alert does not always indicate a vulnerability, as step outputs are often derived from trusted sources and cannot be controlled by an attacker. However, if the step output originates from user-controlled data (such as issue comments, pull request titles, or commit messages), it may be exploitable.

If a step output is user-controlled, 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

First, determine whether the step output can actually be controlled by an attacker. Trace the data flow from the step that sets the output to understand where the value originates. If the output is derived from trusted sources (such as hardcoded values, repository settings, or authenticated API responses), the risk is minimal.

If the step output can be user-controlled, 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 lets attackers inject an arbitrary shell command if output `message` of the step `get-message` is derived from user-controlled data:

```yaml
jobs:
echo-message:
runs-on: ubuntu-latest
steps:
- id: get-message
run: |
# If this value comes from user input, it is vulnerable
echo "message=$USER_INPUT" >> $GITHUB_OUTPUT
- run: |
echo '${{ steps.get-message.outputs.message }}'
```

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

```yaml
jobs:
echo-message:
runs-on: ubuntu-latest
steps:
- id: get-message
run: |
echo "message=$USER_INPUT" >> $GITHUB_OUTPUT
- env:
MESSAGE: ${{ steps.get-message.outputs.message }}
run: |
echo '${{ env.MESSAGE }}'
```

### Correct Usage

The following example uses shell syntax to read the environment variable and will prevent the attack:

```yaml
jobs:
echo-message:
runs-on: ubuntu-latest
steps:
- id: get-message
run: |
echo "message=$USER_INPUT" >> $GITHUB_OUTPUT
- env:
MESSAGE: ${{ steps.get-message.outputs.message }}
run: |
echo "$MESSAGE"
```

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

```yaml
jobs:
echo-message:
runs-on: ubuntu-latest
steps:
- id: get-message
run: |
echo "message=$USER_INPUT" >> $GITHUB_OUTPUT
- uses: actions/github-script@v4
env:
MESSAGE: ${{ steps.get-message.outputs.message }}
with:
script: |
const { MESSAGE } = process.env
...
```

## 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: [Permissions for the GITHUB_TOKEN](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token).
26 changes: 26 additions & 0 deletions actions/ql/src/Security/CWE-094/CodeInjectionLow.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @name Code injection
* @description Interpreting unsanitized user input as code allows a malicious user to perform arbitrary
* code execution.
* @kind path-problem
* @problem.severity warning
* @security-severity 5.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 CodeInjectionFromStepOutputFlow::PathGraph

from
CodeInjectionFromStepOutputFlow::PathNode source, CodeInjectionFromStepOutputFlow::PathNode sink
where lowSeverityCodeInjection(source, sink)
select sink.getNode(), source, sink,
"Potential code injection in $@, which may be controlled by an external user because it comes from a step output.",
sink, sink.getNode().asExpr().(Expression).getRawExpression()
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: newQuery
---
* Add a new query `actions/code-injection/low` to detect potential code injection vulnerabilities in GitHub Actions workflows where data flows from a step output to a code injection sink.
Loading