Skip to main content

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: or github-script code execution contexts. The narrowed scope covers free-text and ref fields such as github.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, and github.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_dispatch input is treated exactly like a workflow_call input. Inputs declared boolean or number are 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 typed boolean or number. Compound expressions such as ${{ inputs.max-age-days || 30 }} are classified on every input they reference, so ${{ inputs.a || inputs.b }} is reported when either a or b is unsafe.
    • Indirect derivations through steps.*, needs.*, matrix.*, and env.* are intentionally out of scope to keep the rule deterministic and low-noise.

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.
  • Reviewed Poutine exceptions are configured in .poutine.yml by rule and path. They do 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-injection query models untrusted sources as github.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 by scripts/security/Assert-WorkflowProjectDirectory.ps1 before 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 a rule-and-path skip only when a checkout is genuinely trusted and the exception has been reviewed:

skip:
- rule: untrusted_checkout_exec
path:
- .github/workflows/example.yml

Poutine also supports skip entries by job, level, OSV ID, or package URL. Prefer the narrowest combination that matches the reviewed finding.

Use this only for a legitimate trusted checkout. Review expectations:

  • The checkout target must be a trusted constant or otherwise intentionally approved.
  • The configuration entry should be added only after review confirms that the workflow 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:

  1. 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-injection at level: error.
  2. Locate the run: or github-script block that interpolates an untrusted event value.
  3. 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.
  4. Re-run npm run lint:dangerous-workflow and 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.


🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.