In this article
Dangerous Workflow Detection
Overview
This page documents the defensive CI control that guards against risky GitHub Actions workflow patterns before merge. The control is a hybrid of two complementary parts:
- A homegrown template-injection gate that runs in PR validation as a fast, deterministic, offline blocking check and surfaces findings in the Security tab as SARIF.
- The Poutine supply-chain scanner, which runs as a broad advisory scanner in CI and uploads its findings to the Security tab as SARIF without blocking merge.
The homegrown gate (blocking)
The homegrown check enforces one rule with two detection sources, both reported under the same rule identifier:
dangerous-workflow/template-injection- Untrusted event values. Triggered when attacker-controllable GitHub event values are
interpolated directly into
run:orgithub-scriptcode execution contexts. The narrowed scope covers free-text and ref fields such asgithub.event.pull_request.title,github.event.pull_request.body,github.event.pull_request.head.ref,github.event.pull_request.head.label,github.event.issue.title,github.event.issue.body,github.event.comment.body,github.event.review.body,github.event.review_comment.body,github.event.discussion.title,github.event.discussion.body,github.event.head_commit.message,github.event.head_commit.author.*,github.event.commits[*].message,github.event.commits[*].author.*,github.event.workflow_run.head_branch,github.event.workflow_run.display_title,github.event.pages[*].page_name, andgithub.head_ref. - Workflow inputs. Triggered when a workflow input is interpolated directly into the same
execution contexts. Input declarations are resolved from every trigger that declares them, so a
workflow_dispatchinput is treated exactly like aworkflow_callinput. Inputs declaredbooleanornumberare validated by GitHub before the runner receives them and are never reported; a name declared by more than one trigger is exempt only when every one of its declarations is typedbooleanornumber. Compound expressions such as${{ inputs.max-age-days || 30 }}are classified on every input they reference, so${{ inputs.a || inputs.b }}is reported when eitheraorbis unsafe. - Indirect derivations through
steps.*,needs.*,matrix.*, andenv.*are intentionally out of scope to keep the rule deterministic and low-noise.
- Untrusted event values. Triggered when attacker-controllable GitHub event values are
interpolated directly into
The input rule detects deviation from the safe pattern rather than attempting to prove a value is
tainted. The safe pattern is a step-level env: mapping read through native shell syntax:
- name: Run validation
env:
INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }}
run: |
echo "$INPUT_WORKING_DIRECTORY"
The value then reaches the shell as data and is never parsed as command structure, so the control holds without filtering the value first.
This gate is PowerShell-native, has no runtime dependencies, and runs offline as part of
npm run validate:local.
Broad coverage via Poutine (advisory)
Broader dangerous-workflow coverage is delegated to Poutine, an Apache-2.0 supply-chain scanner maintained by BoostSecurity. Poutine runs in CI and reports a wider class of issues as advisory SARIF, including:
- Untrusted checkout / arbitrary code execution from untrusted code changes (
untrusted_checkout_exec). - Template injection across a broader surface than the homegrown gate.
- Unpinned or unverified third-party actions.
- Pull-request jobs running on self-hosted runners.
- Known vulnerabilities in build-platform components.
Poutine findings appear in the Security tab under the poutine category. They are advisory
and do not block merge; the homegrown template-injection gate is the only required check.
Scope and limitations
The split is deliberate:
- The homegrown gate stays narrow, deterministic, and offline so it can block with near-zero false positives.
- Poutine provides breadth and is maintained upstream, but runs advisory to avoid a noisy hard gate.
- The
# poutine:ignore untrusted_checkout_execmarker is honored by Poutine to acknowledge reviewed checkout exceptions. It does not affect the homegrown template-injection gate. - Taint-based expansion of the injection rule (indirect derivations) remains tracked as follow-on work.
CodeQL's
actions/code-injectionquery models untrusted sources asgithub.event.*values, so it does not report workflow-input interpolation either; the homegrown input rule covers that case. - A repository-derived value that reaches a
run:block through a dynamic job matrix is an indirect derivation and is not reported here. Project discovery paths are validated separately byscripts/security/Assert-WorkflowProjectDirectory.ps1before they enter a matrix.
Why this exists
This control closes a pre-merge gap. OpenSSF Scorecard can report dangerous workflow findings, but it only runs on the default branch, so a risky workflow can reach the PR branch and merge before the repository sees the issue. The homegrown gate brings a deterministic blocking signal into PR validation, and Poutine adds broad supply-chain coverage on top.
Run it locally
Run the homegrown gate:
npm run lint:dangerous-workflow
The command scans .github/workflows and writes results to the local logs directory. Poutine is a
CI-only scanner and is not part of the offline lint pipeline.
Suppression
Use the suppression marker only when a checkout is genuinely trusted and the exception has been reviewed. The marker is recognized by Poutine on the checkout step line itself or on the immediately preceding line:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # poutine:ignore untrusted_checkout_exec
with:
ref: ${{ github.ref }}
Advisory Poutine findings can also be acknowledged in .poutine.yml by rule, path, or level once
triaged.
Use this only for a legitimate trusted checkout. Review expectations:
- The checkout target must be a trusted constant or otherwise intentionally approved.
- The comment should be added only after a human review confirms that the workflow truly needs the exception.
- Suppressions should be temporary and removed when the workflow is refactored to a safer pattern.
Triage flow
When the required homegrown check fails, resolve the blocking dangerous-workflow/template-injection finding:
- Open the failing check and read the SARIF finding for the affected workflow and line. The homegrown gate emits the stable rule ID
dangerous-workflow/template-injectionatlevel: error. - Locate the
run:orgithub-scriptblock that interpolates an untrusted event value. - Replace the interpolation with a trusted value, route the untrusted value through an intermediate
env:variable, or restructure the workflow so the untrusted payload is never executed as code. - Re-run
npm run lint:dangerous-workflowand re-check the PR validation status.
Advisory Poutine findings such as untrusted_checkout_exec appear separately in the Security tab under the poutine category and do not block merge. Triage them by hardening the workflow or acknowledging the finding in .poutine.yml.
Related documentation
🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.