Skip to content

Commit e70ba7d

Browse files
committed
Improve clobbering query message and documentation
1 parent 1764c65 commit e70ba7d

7 files changed

Lines changed: 157 additions & 10 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: fix
3+
---
4+
* The `actions/output-clobbering/high` query now provides messages tailored to the affected output channel and includes expanded documentation and recommendations.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
## Overview
2+
3+
GitHub Actions steps communicate output values to the runner through a line-oriented command format. A step normally sets an output by appending a `name=value` record to the file referenced by `GITHUB_OUTPUT`. Multiline values use a delimiter-based form. Older workflows may instead emit `set-output` workflow commands to standard output.
4+
5+
If attacker-controlled data is written to one of these command channels without validation, the data may be interpreted as command syntax rather than as a single value. An attacker can use newline characters, a matching multiline delimiter, or a forged workflow command to create additional outputs or overwrite output values that later steps expect to be trusted.
6+
7+
The attacker-controlled data may come directly from an event, or indirectly from an untrusted checkout, downloaded artifact, file, or action output. Clobbered outputs can alter conditions and arguments in later steps. If a later step interpolates an injected output into a script, this issue may contribute to arbitrary code execution.
8+
9+
## Recommendation
10+
11+
Treat values from events, pull requests, artifacts, untrusted files, and third-party actions as untrusted.
12+
13+
Before writing an untrusted value to `GITHUB_OUTPUT`, validate it against the narrow format required by the workflow. For example, require a pull request number to contain only decimal digits. For a single-line output, reject carriage-return and newline characters. Do not append an untrusted file directly to `GITHUB_OUTPUT`.
14+
15+
Do not use the deprecated `set-output` workflow command. Migrate to `GITHUB_OUTPUT`, and avoid printing untrusted data while legacy workflow-command processing is enabled.
16+
17+
For multiline values, use a random delimiter that cannot occur on a line by itself in the value. If the value is arbitrary, store it in a normal file instead of using the multiline command format, and pass only the validated file path as an output.
18+
19+
Review the documentation and implementation of actions that consume untrusted inputs. Use only inputs that the action handles as data rather than as output-command syntax.
20+
21+
## Example
22+
23+
### Incorrect Usage
24+
25+
The following step reads an attacker-controlled artifact file and writes its contents directly to `GITHUB_OUTPUT`. A newline in `pr-number.txt` can add another output record and overwrite `approved`.
26+
27+
```yaml
28+
- id: metadata
29+
run: |
30+
echo "approved=false" >> "$GITHUB_OUTPUT"
31+
echo "pr_number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
32+
```
33+
34+
For example, an attacker can provide a `pr-number.txt` artifact with the following contents:
35+
36+
```text
37+
123
38+
approved=true
39+
```
40+
41+
The step appends the following records to `GITHUB_OUTPUT`:
42+
43+
```text
44+
approved=false
45+
pr_number=123
46+
approved=true
47+
```
48+
49+
The injected record replaces the expected `approved` output with the attacker-controlled value
50+
`true`.
51+
52+
Likewise, printing untrusted data to standard output can forge a legacy workflow command:
53+
54+
```yaml
55+
- id: metadata
56+
env:
57+
BODY: ${{ github.event.comment.body }}
58+
run: |
59+
echo "$BODY"
60+
echo "::set-output name=approved::false"
61+
```
62+
63+
### Correct Usage
64+
65+
Validate the value before writing it to `GITHUB_OUTPUT`, and use a fixed output name with a single-line value:
66+
67+
```yaml
68+
- id: metadata
69+
run: |
70+
pr_number="$(cat pr-number.txt)"
71+
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
72+
echo "Invalid pull request number" >&2
73+
exit 1
74+
fi
75+
printf 'pr_number=%s\n' "$pr_number" >> "$GITHUB_OUTPUT"
76+
```
77+
78+
## References
79+
80+
- GitHub Docs: [Workflow commands for GitHub Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands).
81+
- GitHub Docs: [Setting an output parameter](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-output-parameter).
82+
- GitHub Docs: [Multiline strings](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#multiline-strings).
83+
- GitHub Changelog: [Deprecating `save-state` and `set-output` commands](https://github.blog/changelog/2022-10-10-github-actions-deprecating-save-state-and-set-output-commands/).
84+
- GitHub Actions Toolkit: [`add-path` and `set-env` runner commands are processed via stdout](https://github.com/actions/toolkit/security/advisories/GHSA-mfwh-5m23-j46w).
85+
- GitHub Security Lab: [New vulnerability patterns and mitigation strategies](https://securitylab.github.com/resources/github-actions-new-patterns-and-mitigations/).
86+
- GitHub Security Lab: [Actions expression injection in Ant Design](https://securitylab.github.com/advisories/GHSL-2024-121_GHSL-2024-122_ant-design/).
87+
- GitHub Security Lab: [Poisoned Pipeline Execution via code injection in SymPy](https://securitylab.github.com/advisories/GHSL-2024-322_Sympy/).
88+
- Common Weakness Enumeration: [CWE-74](https://cwe.mitre.org/data/definitions/74.html).

actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.ql

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,42 @@ import codeql.actions.dataflow.FlowSources
1919
import OutputClobberingFlow::PathGraph
2020
import codeql.actions.security.ControlChecks
2121

22+
private predicate isEnvironmentFileSink(OutputClobberingFlow::PathNode sink) {
23+
sink.getNode() instanceof OutputClobberingFromFileReadSink or
24+
sink.getNode() instanceof OutputClobberingFromEnvVarSink
25+
}
26+
27+
private predicate isWorkflowCommandSink(OutputClobberingFlow::PathNode sink) {
28+
sink.getNode() instanceof WorkflowCommandClobberingFromFileReadSink or
29+
sink.getNode() instanceof WorkflowCommandClobberingFromEnvVarSink
30+
}
31+
32+
private string getMessage(OutputClobberingFlow::PathNode sink) {
33+
isEnvironmentFileSink(sink) and
34+
result =
35+
"Attacker-controlled data may inject or overwrite step outputs written through " +
36+
"`$GITHUB_OUTPUT` in $@."
37+
or
38+
not isEnvironmentFileSink(sink) and
39+
isWorkflowCommandSink(sink) and
40+
result =
41+
"Attacker-controlled data printed to standard output may forge a `set-output` " +
42+
"workflow command and overwrite step outputs in $@."
43+
or
44+
not isEnvironmentFileSink(sink) and
45+
not isWorkflowCommandSink(sink) and
46+
result = "Attacker-controlled data may inject or overwrite step outputs in $@."
47+
}
48+
49+
private string getSinkLabel(OutputClobberingFlow::PathNode sink) {
50+
(isEnvironmentFileSink(sink) or isWorkflowCommandSink(sink)) and
51+
result = "this step"
52+
or
53+
not isEnvironmentFileSink(sink) and
54+
not isWorkflowCommandSink(sink) and
55+
result = "this action"
56+
}
57+
2258
from OutputClobberingFlow::PathNode source, OutputClobberingFlow::PathNode sink, Event event
2359
where
2460
OutputClobberingFlow::flowPath(source, sink) and
@@ -40,5 +76,4 @@ where
4076
madSink(sink.getNode(), "output-clobbering")
4177
)
4278
)
43-
select sink.getNode(), source, sink, "Potential clobbering of a step output in $@.", sink,
44-
sink.getNode().toString()
79+
select sink.getNode(), source, sink, getMessage(sink), sink, getSinkLabel(sink)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
extensions:
2+
- addsTo:
3+
pack: codeql/actions-all
4+
extensible: actionsSinkModel
5+
data:
6+
- ["actions/github-script", "*", "input.script", "output-clobbering", "manual"]

actions/ql/test/qlpack.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ dependencies:
1010
extractor: actions
1111
tests: .
1212
warnOnImplicitThis: true
13+
dataExtensions:
14+
- output-clobbering.model.yml
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
on:
2+
issue_comment: {}
3+
4+
jobs:
5+
modeled-action:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/github-script@v7
9+
with:
10+
script: ${{ github.event.comment.body }}

actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ nodes
2121
| .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | semmle.label | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n |
2222
| .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | semmle.label | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n |
2323
| .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | semmle.label | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n |
24+
| .github/workflows/output3.yml:10:20:10:51 | github.event.comment.body | semmle.label | github.event.comment.body |
2425
subpaths
2526
#select
26-
| .github/workflows/output1.yml:10:14:13:50 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | .github/workflows/output1.yml:9:18:9:49 | github.event.comment.body | .github/workflows/output1.yml:10:14:13:50 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | Potential clobbering of a step output in $@. | .github/workflows/output1.yml:10:14:13:50 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n |
27-
| .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n | .github/workflows/output1.yml:30:9:35:6 | Uses Step | .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n | Potential clobbering of a step output in $@. | .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n |
28-
| .github/workflows/output2.yml:10:14:13:48 | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:9:18:9:49 | github.event.comment.body | .github/workflows/output2.yml:10:14:13:48 | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:10:14:13:48 | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n |
29-
| .github/workflows/output2.yml:17:14:20:21 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n | .github/workflows/output2.yml:16:18:16:49 | github.event.comment.body | .github/workflows/output2.yml:17:14:20:21 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:17:14:20:21 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n |
30-
| .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n |
31-
| .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n |
32-
| .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n |
33-
| .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n |
27+
| .github/workflows/output1.yml:10:14:13:50 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | .github/workflows/output1.yml:9:18:9:49 | github.event.comment.body | .github/workflows/output1.yml:10:14:13:50 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | Attacker-controlled data may inject or overwrite step outputs written through `$GITHUB_OUTPUT` in $@. | .github/workflows/output1.yml:10:14:13:50 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | this step |
28+
| .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n | .github/workflows/output1.yml:30:9:35:6 | Uses Step | .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n | Attacker-controlled data may inject or overwrite step outputs written through `$GITHUB_OUTPUT` in $@. | .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(<pr-number)" >> $GITHUB_OUTPUT\n | this step |
29+
| .github/workflows/output2.yml:10:14:13:48 | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:9:18:9:49 | github.event.comment.body | .github/workflows/output2.yml:10:14:13:48 | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n | Attacker-controlled data printed to standard output may forge a `set-output` workflow command and overwrite step outputs in $@. | .github/workflows/output2.yml:10:14:13:48 | # VULNERABLE\necho $BODY\necho "::set-output name=OUTPUT::SAFE"\n | this step |
30+
| .github/workflows/output2.yml:17:14:20:21 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n | .github/workflows/output2.yml:16:18:16:49 | github.event.comment.body | .github/workflows/output2.yml:17:14:20:21 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n | Attacker-controlled data printed to standard output may forge a `set-output` workflow command and overwrite step outputs in $@. | .github/workflows/output2.yml:17:14:20:21 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\necho $BODY\n | this step |
31+
| .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n | Attacker-controlled data printed to standard output may forge a `set-output` workflow command and overwrite step outputs in $@. | .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(<pr-number)"\necho "$PR"\necho "::set-output name=OUTPUT::SAFE"\n | this step |
32+
| .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | Attacker-controlled data printed to standard output may forge a `set-output` workflow command and overwrite step outputs in $@. | .github/workflows/output2.yml:48:14:51:48 | # VULNERABLE\ncat pr-number\necho "::set-output name=OUTPUT::SAFE"\n | this step |
33+
| .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | Attacker-controlled data printed to standard output may forge a `set-output` workflow command and overwrite step outputs in $@. | .github/workflows/output2.yml:53:14:56:19 | # VULNERABLE\necho "::set-output name=OUTPUT::SAFE"\nls *.txt\n | this step |
34+
| .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | Attacker-controlled data printed to standard output may forge a `set-output` workflow command and overwrite step outputs in $@. | .github/workflows/output2.yml:58:14:62:48 | # VULNERABLE\nCURRENT_VERSION=$(cat gradle.properties \| sed -n '/^version=/ { s/^version=//;p }')\necho "$CURRENT_VERSION"\necho "::set-output name=OUTPUT::SAFE"\n | this step |
35+
| .github/workflows/output3.yml:10:20:10:51 | github.event.comment.body | .github/workflows/output3.yml:10:20:10:51 | github.event.comment.body | .github/workflows/output3.yml:10:20:10:51 | github.event.comment.body | Attacker-controlled data may inject or overwrite step outputs in $@. | .github/workflows/output3.yml:10:20:10:51 | github.event.comment.body | this action |

0 commit comments

Comments
 (0)