From 97a39e8819a114ec17c4fc4451c1ee8515d448fa Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:30:31 +0100 Subject: [PATCH] Block dispatch payload shell injection Change-source: pulse-maintainer --- scripts/check_workflow_trust.py | 21 +++++++++++++++------ scripts/tests/test_workflow_trust.py | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index f3198722f..208769766 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -24,14 +24,22 @@ USES_RE = re.compile( ) RUN_RE = re.compile(rf"^(\s*)(?:-\s*)?{_yaml_key('run')}\s*:\s*(.*)$") EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}") -# Workflow-call and dispatch inputs are data, not shell source. Step and job -# outputs are data too: they can carry event or input values across an -# otherwise-safe intermediate step. Secrets include github.token because +# Workflow-call and dispatch inputs are data, not shell source. The legacy +# github.event.inputs alias is identical data, and repository_dispatch callers +# fully control client_payload. Step and job outputs are data too: they can +# carry event or input values across an otherwise-safe intermediate step. +# Whole github contexts are unsafe because they include event data (and the +# github context includes github.token). Secrets include github.token because # Actions makes that credential available independently of an explicit # secrets.GITHUB_TOKEN reference. SHELL_DATA_CONTEXT_RE = re.compile( r"(? list[Finding]: Finding( path, script_index + 1, - "workflow inputs, secrets, and step/job outputs " - "must enter run scripts through env", + "workflow inputs, dispatch payloads, secrets, " + "GitHub contexts, and step/job outputs must enter " + "run scripts through env", ) ) elif UNTRUSTED_GITHUB_CONTEXT_RE.search(expression): diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index 5c19ecb4f..23b5985c3 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -412,6 +412,33 @@ jobs: 3, ) + def test_rejects_dispatch_data_and_whole_github_contexts_in_shell(self) -> None: + findings = self.audit( + """permissions: {} +jobs: + unsafe: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - run: echo "${{ github.event.inputs.release_name }}" + - run: echo "${{ github.event.client_payload.command }}" + - run: echo "${{ github['event']['inputs']['release_name'] }}" + - run: echo "${{ github.event['client_payload']['command'] }}" + - run: echo "${{ toJSON(github.event) }}" + - run: echo "${{ toJSON(github) }}" + - run: echo "${{ github['token'] }}" + - env: + RELEASE_NAME: ${{ github.event.inputs.release_name }} + COMMAND: ${{ github.event.client_payload.command }} + EVENT_JSON: ${{ toJSON(github.event) }} + run: printf '%s %s %s\n' "$RELEASE_NAME" "$COMMAND" "$EVENT_JSON" +""" + ) + self.assertEqual( + sum("must enter run scripts through env" in finding for finding in findings), + 7, + ) + def test_rejects_untrusted_github_metadata_in_generated_shell(self) -> None: findings = self.audit( """on: [pull_request]