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.

Standing default-branch coverage​

weekly-security-maintenance.yml calls the same reusable dangerous-workflow-scan.yml every Sunday at 02:00 UTC on the default branch. Manual dispatch scans the selected ref; select main when collecting a merged-state baseline. Both scanners are advisory in this maintenance caller. The homegrown check remains blocking in PR validation.

The caller grants only contents: read and security-events: write, enabling SARIF uploads without repository-content write access. Poutine retains its pinned action revision, the poutine SARIF category, and the poutine-results artifact with 90-day retention. The maintenance summary reports job execution, not a finding-free scan. Because analysis and upload steps tolerate failures, inspect step logs and confirm that a current SARIF artifact exists even when the job succeeds.

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 exact rule and package URL, or workflow path and job where available. 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 acknowledgments only for evidence-backed false positives or explicitly reviewed compensating controls. For a trusted checkout, constrain the rule by path and job:

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

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

For creator-list false positives, use the exact package URL with github_action_from_unverified_creator_used, not an organization-wide or rule-only skip. A Marketplace verified-creator badge establishes publisher verification, not action safety; SHA pinning and least privilege remain necessary.

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.

Reviewed acknowledgments​

  • astral-sh/setup-uv: rule github_action_from_unverified_creator_used, selector purl: pkg:githubactions/astral-sh/setup-uv. Marketplace verifies the creator, but Poutine's static list omits it, as tracked in upstream issue 452. Remove when a pinned scanner upgrade recognizes the verified publisher.
  • googleapis/release-please-action: rule github_action_from_unverified_creator_used, selector purl: pkg:githubactions/googleapis/release-please-action. Its Marketplace listing verifies the creator, but Poutine's static list omits it. Remove when a pinned scanner upgrade recognizes the verified publisher.
  • Marketplace preparation: rule untrusted_checkout_exec, selectors path: .github/workflows/extension-marketplace-publish.yml and job: prepare-publisher. Preparation checks out an API-resolved main commit, verifies HEAD before installation, and has only contents: read, with no protected environment or marketplace credential. Publication happens in a separate protected job. Re-review or remove if the protected-main trust assumption, checkout source, digest checks, permissions, or credential separation changes.
  • PR review: rule default_permissions_on_risky_events, selector path: .github/workflows/pr-review.lock.yml. The generated workflow declares permissions: {}, which grants no scopes to jobs without overrides, but Poutine treats the empty map as absent. Remove when the scanner distinguishes empty permissions from missing permissions. The acknowledgment does not change job grants or the permissions hard gate.
  • Alert 340 identifies EndBug/label-sync, which lacks a Marketplace verified-creator badge. Retain this advisory finding as accepted provenance risk, not a false positive. The action remains full-SHA-pinned, with job-scoped permissions and normal staleness monitoring. Reassess on action updates or a change in publisher verification.

Maintainers should verify each removal against hosted SARIF, not job success alone.

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 appear separately under the poutine category and do not block merge:

  1. Record the scanner revision, scanned commit/ref, complete SARIF, rule, path, job, and package identity where available.
  2. Classify each finding as actionable, a verified false positive, or an accepted residual risk. Cite source controls or upstream evidence; advisory status alone is not a disposition.
  3. Harden actionable findings. Acknowledge proven false positives with the narrowest supported selector and a removal condition. Keep accepted-risk signal visible unless maintainers explicitly decide otherwise.
  4. After publication, inspect hosted SARIF and step logs for the actual change. Confirm scheduled default-branch coverage after merge.
  5. Reconcile remote alerts and review threads only with current hosted evidence and separate authorization. A local contract test does not prove remote resolution.

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