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.
  • The # poutine:ignore untrusted_checkout_exec marker 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-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 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:

  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.