diff --git a/actions/ql/lib/codeql/actions/security/OutputClobberingQuery.qll b/actions/ql/lib/codeql/actions/security/OutputClobberingQuery.qll index 22b4879df126..7d560f8b6242 100644 --- a/actions/ql/lib/codeql/actions/security/OutputClobberingQuery.qll +++ b/actions/ql/lib/codeql/actions/security/OutputClobberingQuery.qll @@ -111,6 +111,43 @@ class WorkflowCommandClobberingFromEnvVarSink extends OutputClobberingSink { } } +private string jqSafeOptionRegexp() { + result = "-[acCMeRnSs]+" + or + result = + "--(ascii-output|color-output|compact-output|exit-status|monochrome-output|null-input|" + + "raw-input|slurp|sort-keys|unbuffered)" +} + +private string jqSimpleFilterRegexp() { + result = "\\." + or + result = "\\.[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*|\\[[0-9]+\\])*" +} + +private string jqSimpleFilterArgumentRegexp() { + result = jqSimpleFilterRegexp() + or + result = "'" + jqSimpleFilterRegexp() + "'" + or + result = "\"" + jqSimpleFilterRegexp() + "\"" +} + +private string jqLiteralInputRegexp() { + result = "[A-Za-z0-9_./][A-Za-z0-9_./-]*" + or + result = "\\$GITHUB_EVENT_PATH" + or + result = "\\$\\{GITHUB_EVENT_PATH\\}" +} + +bindingset[command] +private predicate jqProducesJsonEncodedOutput(string command) { + command + .regexpMatch("jq(\\s+" + jqSafeOptionRegexp() + ")*\\s+" + jqSimpleFilterArgumentRegexp() + + "(\\s+" + jqSafeOptionRegexp() + ")*(\\s+" + jqLiteralInputRegexp() + ")*") +} + /** * - id: clob1 * run: | @@ -165,7 +202,8 @@ class WorkflowCommandClobberingFromFileReadSink extends OutputClobberingSink { // - run: cat pr-id.txt clobbering_stmt.indexOf(clobbering_cmd) = 0 ) - ) + ) and + not jqProducesJsonEncodedOutput(clobbering_cmd) ) } } diff --git a/actions/ql/src/change-notes/2026-07-29-output-clobbering-jq-precision.md b/actions/ql/src/change-notes/2026-07-29-output-clobbering-jq-precision.md new file mode 100644 index 000000000000..9fba403a5715 --- /dev/null +++ b/actions/ql/src/change-notes/2026-07-29-output-clobbering-jq-precision.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `actions/output-clobbering/high` query no longer reports simple `jq` path filters when their output remains JSON-encoded. Raw-output modes, complex filters, and unrecognized options remain reportable. diff --git a/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output2.yml b/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output2.yml index 614de61b0cb7..896f6c820152 100644 --- a/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output2.yml +++ b/actions/ql/test/query-tests/Security/CWE-074/.github/workflows/output2.yml @@ -60,3 +60,57 @@ jobs: CURRENT_VERSION=$(cat gradle.properties | sed -n '/^version=/ { s/^version=//;p }') echo "$CURRENT_VERSION" echo "::set-output name=OUTPUT::SAFE" + - id: clob5 + run: | + # NOT VULNERABLE: jq emits JSON-encoded strings by default + jq '.value' pr-number.json + - id: clob6 + run: | + # VULNERABLE: raw output can begin with a workflow command + jq -r '.value' pr-number.json + - id: clob7 + run: | + # VULNERABLE: long raw-output option after the filter + jq '.value' --raw-output pr-number.json + - id: clob8 + run: | + # VULNERABLE: combined short options include raw output + jq -Mcr '.value' pr-number.json + - id: clob9 + run: | + # NOT VULNERABLE: assigned jq output remains JSON encoded + VALUE=$(jq '.value' pr-number.json) + echo "$VALUE" + - id: clob10 + run: | + # VULNERABLE: assigned raw output can begin with a workflow command + VALUE=$(jq --raw-output '.value' pr-number.json) + echo "$VALUE" + - id: clob11 + run: | + # NOT VULNERABLE: combined options preserve JSON encoding + jq -Mc '.value' pr-number.json + - id: clob12 + run: | + # NOT VULNERABLE: safe long options may follow a simple filter + jq '.value' --compact-output pr-number.json + - id: clob13 + run: | + # VULNERABLE: join output emits strings without JSON encoding + jq -j '.value' pr-number.json + - id: clob14 + run: | + # VULNERABLE: the long join-output option also emits raw strings + jq '.value' --join-output pr-number.json + - id: clob15 + run: | + # VULNERABLE: raw-output0 emits strings without JSON encoding + jq '.value' --raw-output0 pr-number.json + - id: clob16 + run: | + # VULNERABLE: stderr emits its input without JSON encoding + jq '.value | stderr' pr-number.json + - id: clob17 + run: | + # VULNERABLE: halt_error emits its input without JSON encoding + jq '.value | halt_error(1)' pr-number.json 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 af792f1ab65e..58b0df462e48 100644 --- a/actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected +++ b/actions/ql/test/query-tests/Security/CWE-074/OutputClobberingHigh.expected @@ -7,6 +7,15 @@ edges | .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 | provenance | Config | | .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 | provenance | Config | | .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 | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:68:14:70:40 | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:72:14:74:50 | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:76:14:78:42 | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:85:14:88:24 | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:98:14:100:40 | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:102:14:104:51 | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:106:14:108:51 | # VULNERABLE: raw-output0 emits strings without JSON encoding\njq '.value' --raw-output0 pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:110:14:112:46 | # VULNERABLE: stderr emits its input without JSON encoding\njq '.value \| stderr' pr-number.json\n | provenance | Config | +| .github/workflows/output2.yml:36:9:41:6 | Uses Step | .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 | provenance | Config | nodes | .github/workflows/output1.yml:9:18:9:49 | github.event.comment.body | semmle.label | 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 | semmle.label | # VULNERABLE\necho "OUTPUT_1=HARDCODED" >> $GITHUB_OUTPUT\necho "OUTPUT_2=$BODY" >> $GITHUB_OUTPUT\n | @@ -21,6 +30,15 @@ nodes | .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 | | .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 | | .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 | +| .github/workflows/output2.yml:68:14:70:40 | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | semmle.label | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | +| .github/workflows/output2.yml:72:14:74:50 | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | semmle.label | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | +| .github/workflows/output2.yml:76:14:78:42 | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | semmle.label | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | +| .github/workflows/output2.yml:85:14:88:24 | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | semmle.label | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | +| .github/workflows/output2.yml:98:14:100:40 | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | semmle.label | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | +| .github/workflows/output2.yml:102:14:104:51 | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | semmle.label | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | +| .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 | 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 | @@ -31,3 +49,12 @@ subpaths | .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 | | .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 | | .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 | +| .github/workflows/output2.yml:68:14:70:40 | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:68:14:70:40 | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:68:14:70:40 | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | # VULNERABLE: raw output can begin with a workflow command\njq -r '.value' pr-number.json\n | +| .github/workflows/output2.yml:72:14:74:50 | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:72:14:74:50 | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:72:14:74:50 | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | # VULNERABLE: long raw-output option after the filter\njq '.value' --raw-output pr-number.json\n | +| .github/workflows/output2.yml:76:14:78:42 | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:76:14:78:42 | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:76:14:78:42 | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | # VULNERABLE: combined short options include raw output\njq -Mcr '.value' pr-number.json\n | +| .github/workflows/output2.yml:85:14:88:24 | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:85:14:88:24 | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:85:14:88:24 | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | # VULNERABLE: assigned raw output can begin with a workflow command\nVALUE=$(jq --raw-output '.value' pr-number.json)\necho "$VALUE"\n | +| .github/workflows/output2.yml:98:14:100:40 | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:98:14:100:40 | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:98:14:100:40 | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | # VULNERABLE: join output emits strings without JSON encoding\njq -j '.value' pr-number.json\n | +| .github/workflows/output2.yml:102:14:104:51 | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:102:14:104:51 | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:102:14:104:51 | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | # VULNERABLE: the long join-output option also emits raw strings\njq '.value' --join-output pr-number.json\n | +| .github/workflows/output2.yml:106:14:108:51 | # VULNERABLE: raw-output0 emits strings without JSON encoding\njq '.value' --raw-output0 pr-number.json\n | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:106:14:108:51 | # VULNERABLE: raw-output0 emits strings without JSON encoding\njq '.value' --raw-output0 pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:106:14:108:51 | # VULNERABLE: raw-output0 emits strings without JSON encoding\njq '.value' --raw-output0 pr-number.json\n | # 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 | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .github/workflows/output2.yml:110:14:112:46 | # VULNERABLE: stderr emits its input without JSON encoding\njq '.value \| stderr' pr-number.json\n | Potential clobbering of a step output in $@. | .github/workflows/output2.yml:110:14:112:46 | # VULNERABLE: stderr emits its input without JSON encoding\njq '.value \| stderr' pr-number.json\n | # 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 | .github/workflows/output2.yml:36:9:41:6 | Uses Step | .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 | Potential clobbering of a step output in $@. | .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 | # VULNERABLE: halt_error emits its input without JSON encoding\njq '.value \| halt_error(1)' pr-number.json\n |