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.
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.ymlby 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-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 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: rulegithub_action_from_unverified_creator_used, selectorpurl: 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: rulegithub_action_from_unverified_creator_used, selectorpurl: 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, selectorspath: .github/workflows/extension-marketplace-publish.ymlandjob: prepare-publisher. Preparation checks out an API-resolvedmaincommit, verifiesHEADbefore installation, and has onlycontents: 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, selectorpath: .github/workflows/pr-review.lock.yml. The generated workflow declarespermissions: {}, 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:
- 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 appear separately under the poutine category and do not block merge:
- Record the scanner revision, scanned commit/ref, complete SARIF, rule, path, job, and package identity where available.
- 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.
- 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.
- After publication, inspect hosted SARIF and step logs for the actual change. Confirm scheduled default-branch coverage after merge.
- Reconcile remote alerts and review threads only with current hosted evidence and separate authorization. A local contract test does not prove remote resolution.
Related documentation
🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.