diff --git a/actions/ql/src/change-notes/2026-07-29-output-clobbering-messages.md b/actions/ql/src/change-notes/2026-07-29-output-clobbering-messages.md new file mode 100644 index 000000000000..4abb7a029762 --- /dev/null +++ b/actions/ql/src/change-notes/2026-07-29-output-clobbering-messages.md @@ -0,0 +1,4 @@ +--- +category: fix +--- +* The `actions/output-clobbering/high` query now provides messages tailored to the affected output channel and includes expanded documentation and recommendations. diff --git a/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.md b/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.md new file mode 100644 index 000000000000..cf8c086e097c --- /dev/null +++ b/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.md @@ -0,0 +1,88 @@ +## Overview + +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. + +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. + +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. + +## Recommendation + +Treat values from events, pull requests, artifacts, untrusted files, and third-party actions as untrusted. + +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`. + +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. + +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. + +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. + +## Example + +### Incorrect Usage + +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`. + +```yaml +- id: metadata + run: | + echo "approved=false" >> "$GITHUB_OUTPUT" + echo "pr_number=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT" +``` + +For example, an attacker can provide a `pr-number.txt` artifact with the following contents: + +```text +123 +approved=true +``` + +The step appends the following records to `GITHUB_OUTPUT`: + +```text +approved=false +pr_number=123 +approved=true +``` + +The injected record replaces the expected `approved` output with the attacker-controlled value +`true`. + +Likewise, printing untrusted data to standard output can forge a legacy workflow command: + +```yaml +- id: metadata + env: + BODY: ${{ github.event.comment.body }} + run: | + echo "$BODY" + echo "::set-output name=approved::false" +``` + +### Correct Usage + +Validate the value before writing it to `GITHUB_OUTPUT`, and use a fixed output name with a single-line value: + +```yaml +- id: metadata + run: | + pr_number="$(cat pr-number.txt)" + if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then + echo "Invalid pull request number" >&2 + exit 1 + fi + printf 'pr_number=%s\n' "$pr_number" >> "$GITHUB_OUTPUT" +``` + +## References + +- GitHub Docs: [Workflow commands for GitHub Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands). +- GitHub Docs: [Setting an output parameter](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-output-parameter). +- GitHub Docs: [Multiline strings](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#multiline-strings). +- 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/). +- 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). +- GitHub Security Lab: [New vulnerability patterns and mitigation strategies](https://securitylab.github.com/resources/github-actions-new-patterns-and-mitigations/). +- GitHub Security Lab: [Actions expression injection in Ant Design](https://securitylab.github.com/advisories/GHSL-2024-121_GHSL-2024-122_ant-design/). +- GitHub Security Lab: [Poisoned Pipeline Execution via code injection in SymPy](https://securitylab.github.com/advisories/GHSL-2024-322_Sympy/). +- Common Weakness Enumeration: [CWE-74](https://cwe.mitre.org/data/definitions/74.html). diff --git a/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.ql b/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.ql index 9c9c2e4d139a..a197ee2b244a 100644 --- a/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.ql +++ b/actions/ql/src/experimental/Security/CWE-074/OutputClobberingHigh.ql @@ -19,6 +19,42 @@ import codeql.actions.dataflow.FlowSources import OutputClobberingFlow::PathGraph import codeql.actions.security.ControlChecks +private predicate isEnvironmentFileSink(OutputClobberingFlow::PathNode sink) { + sink.getNode() instanceof OutputClobberingFromFileReadSink or + sink.getNode() instanceof OutputClobberingFromEnvVarSink +} + +private predicate isWorkflowCommandSink(OutputClobberingFlow::PathNode sink) { + sink.getNode() instanceof WorkflowCommandClobberingFromFileReadSink or + sink.getNode() instanceof WorkflowCommandClobberingFromEnvVarSink +} + +private string getMessage(OutputClobberingFlow::PathNode sink) { + isEnvironmentFileSink(sink) and + result = + "Attacker-controlled data may inject or overwrite step outputs written through " + + "`$GITHUB_OUTPUT` in $@." + or + not isEnvironmentFileSink(sink) and + isWorkflowCommandSink(sink) and + result = + "Attacker-controlled data printed to standard output may forge a `set-output` " + + "workflow command and overwrite step outputs in $@." + or + not isEnvironmentFileSink(sink) and + not isWorkflowCommandSink(sink) and + result = "Attacker-controlled data may inject or overwrite step outputs in $@." +} + +private string getSinkLabel(OutputClobberingFlow::PathNode sink) { + (isEnvironmentFileSink(sink) or isWorkflowCommandSink(sink)) and + result = "this step" + or + not isEnvironmentFileSink(sink) and + not isWorkflowCommandSink(sink) and + result = "this action" +} + from OutputClobberingFlow::PathNode source, OutputClobberingFlow::PathNode sink, Event event where OutputClobberingFlow::flowPath(source, sink) and @@ -40,5 +76,4 @@ where madSink(sink.getNode(), "output-clobbering") ) ) -select sink.getNode(), source, sink, "Potential clobbering of a step output in $@.", sink, - sink.getNode().toString() +select sink.getNode(), source, sink, getMessage(sink), sink, getSinkLabel(sink) diff --git a/actions/ql/test/output-clobbering.model.yml b/actions/ql/test/output-clobbering.model.yml new file mode 100644 index 000000000000..ef94ac69ac41 --- /dev/null +++ b/actions/ql/test/output-clobbering.model.yml @@ -0,0 +1,6 @@ +extensions: + - addsTo: + pack: codeql/actions-all + extensible: actionsSinkModel + data: + - ["actions/github-script", "*", "input.script", "output-clobbering", "manual"] diff --git a/actions/ql/test/qlpack.yml b/actions/ql/test/qlpack.yml index 139e8e57c62e..9c9e714fc67a 100644 --- a/actions/ql/test/qlpack.yml +++ b/actions/ql/test/qlpack.yml @@ -10,3 +10,5 @@ dependencies: extractor: actions tests: . warnOnImplicitThis: true +dataExtensions: + - output-clobbering.model.yml diff --git a/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output3.yml b/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output3.yml new file mode 100644 index 000000000000..15d31880422c --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output3.yml @@ -0,0 +1,10 @@ +on: + issue_comment: {} + +jobs: + modeled-action: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: ${{ github.event.comment.body }} diff --git a/actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected b/actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected index 58b0df462e48..63d385c08aa8 100644 --- a/actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected +++ b/actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected @@ -39,22 +39,24 @@ nodes | .github/workflows/output2.yml:106:14:108:51 | # VULNERABLE: raw-output0 emits strings without JSON encoding\njq '.value' --raw-output0 pr-number.json\n | semmle.label | # VULNERABLE: raw-output0 emits strings without JSON encoding\njq '.value' --raw-output0 pr-number.json\n | | .github/workflows/output2.yml:110:14:112:46 | # VULNERABLE: stderr emits its input without JSON encoding\njq '.value \| stderr' pr-number.json\n | semmle.label | # VULNERABLE: stderr emits its input without JSON encoding\njq '.value \| stderr' pr-number.json\n | | .github/workflows/output2.yml:114:14:116:53 | # VULNERABLE: halt_error emits its input without JSON encoding\njq '.value \| halt_error(1)' pr-number.json\n | semmle.label | # VULNERABLE: halt_error emits its input without JSON encoding\njq '.value \| halt_error(1)' pr-number.json\n | +| .github/workflows/output3.yml:10:20:10:51 | github.event.comment.body | semmle.label | github.event.comment.body | subpaths #select -| .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 | -| .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(> $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=$(> $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=$(> $GITHUB_OUTPUT\n | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(> $GITHUB_OUTPUT\n | -| .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 | -| .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 | -| .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(> $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 | +| .github/workflows/output1.yml:36:14:39:58 | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$(> $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=$(> $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=$(> $GITHUB_OUTPUT\n | this step | +| .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 | +| .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 | +| .github/workflows/output2.yml:42:14:46:48 | # VULNERABLE\nPR="$(