A Commented Checklist for Reviewing an AI-Generated GitHub Actions Workflow Before It Runs on Your Main Branch

Padlock superimposed on a circuit board, representing securing a GitHub Actions workflow

An AI-generated GitHub Actions workflow usually runs on the first try, which is exactly why it gets merged without a careful read. The failure modes that matter are not syntax errors; they are a handful of specific, well-documented patterns — untrusted input reaching a shell command, a third-party action pinned to a movable tag, a token with more access than the job needs — that produce a working pipeline with a real security hole in it. This checklist walks through five of those patterns, what to look for in the YAML, and why each one matters.

Why AI-Generated Workflows Need a Different Kind of Review

A code reviewer checking a pull request naturally focuses on whether the workflow does what it says: does the build step run, does the test job pass, does the deploy step trigger on the right branch. A model generating that same YAML optimizes for exactly the same thing, because “does it run” is the signal both a human reviewer and an AI assistant can see immediately, while a security weakness in the trigger configuration or the token scope produces no visible symptom at all until someone exploits it. That asymmetry is the reason a workflow an AI assistant wrote, tested, and confirmed working can still ship with any of the five issues below.

1. Check the Trigger: Is pull_request_target Doing More Than It Needs To?

The pull_request_target trigger runs with access to repository secrets and a write-scoped token even for pull requests from forks, which is useful for a handful of legitimate cases and dangerous for most others. GitHub’s own security hardening guide is direct about this: avoid the trigger “if it’s not necessary,” and prefer workflow_run for cases that genuinely need privilege separation between untrusted code and privileged actions.

  • Flag it when: the workflow uses pull_request_target and also checks out the pull request’s own branch (actions/checkout with ref: ${{ github.event.pull_request.head.sha }} or similar) rather than the base branch.
  • Why this matters: that combination runs untrusted, attacker-controlled code with a token that can read secrets — the exact scenario the trigger is supposed to require explicit justification for.
  • Ask the assistant to fix it by: switching to pull_request where secrets aren’t needed, or splitting the job into an unprivileged pull_request job and a separate, carefully scoped workflow_run job for anything that needs write access.

2. Search for Untrusted Values Inside run: Steps

A ${{ }} expression that pulls from something a contributor controls — a PR title, an issue title, a commit message, a branch name — and gets interpolated directly into a run: step is a script injection point, because GitHub builds the shell script by substituting that text in before the shell ever sees it. A branch or PR title containing shell metacharacters doesn’t get escaped in that substitution; it becomes part of the command.

Pattern to search forWhy it’s riskyFix
${{ github.event.issue.title }} or .pull_request.title inside run:Title text is substituted directly into the generated shell scriptAssign it to an environment variable first, then reference it as a shell variable (e.g. “$TITLE”) inside the script
${{ github.head_ref }} used unquoted in a shell commandBranch names can contain characters a shell will interpretQuote the shell variable in double quotes; prefer passing values to a JavaScript action instead of a generated script

Our walkthrough on reviewing AI-generated regex for ReDoS before merging covers a related class of problem: a pattern that works on every test case an assistant tries, but fails specifically on the input a human reviewer has to go looking for.

3. Check How Every Third-Party Action Is Pinned

A workflow step like uses: some-org/some-action@v2 looks pinned, but a tag can be moved to point at different code at any time, by anyone with write access to that action’s repository. GitHub’s guidance ranks three options from strongest to weakest: a workflow dependency lockfile that records exact commits for every dependency (including transitive ones) and “supersedes SHA pinning” where it’s available; pinning to a full-length commit SHA verified against the action’s own repository; and, only when you trust the publisher, a tag from an account with a Verified creator badge.

  • Flag it when: any uses: line ends in a version tag (@v2, @v4) or, worse, a branch name like @main, rather than a 40-character commit hash.
  • A detail AI assistants tend to miss: Dependabot alerts don’t fire for SHA-pinned actions, so once you pin to a SHA you need a separate process to check for updates — pinning isn’t a one-time fix you can forget about.
  • Ask the assistant to fix it by: replacing the tag with the exact commit SHA from the action’s own repository (not a fork), and adding a comment noting the version the SHA corresponds to for future readability.

4. Check the permissions: Block — Is It Actually Scoped Down?

The default GITHUB_TOKEN for a workflow can carry broad repository permissions unless a workflow explicitly narrows them, and GitHub’s hardening guide recommends setting the default to read-only on contents and granting broader access only to the specific jobs that need it — a pull-request-labeling job, for instance, needs write access to issues, not to repository contents. The practical reason this matters: any user with write access to the repository can read the secrets available to that token, so an overscoped token quietly widens the blast radius of any later compromise.

  • Flag it when: the workflow has no top-level permissions: block at all (meaning it inherits the repository’s default, which may be broader than this workflow needs) or sets permissions: write-all.
  • Ask the assistant to fix it by: adding permissions: contents: read at the workflow level, then adding only the specific additional scopes (issues: write, pull-requests: write, etc.) each job actually uses.

5. Check Whether Self-Hosted Runners Are Exposed to Forks

If the workflow targets a self-hosted runner and the repository is public, anyone who can open a pull request can potentially run code on that runner. GitHub’s guide states plainly that self-hosted runners “should almost never be used for public repositories” for exactly this reason: a fork-based pull request can reach the runner, and from there potentially the runner’s network access, cached credentials, or other jobs running on the same machine.

Required reviewers on a deployment environment add a human approval gate before environment secrets are exposed, but that gate doesn’t remove the underlying exposure if the job still executes on a shared self-hosted runner. For a public repository, GitHub-hosted runners are the safer default unless there’s a specific, reviewed reason to do otherwise.

Where This Sits in the Broader CI/CD Threat Model

These five checks map onto categories from the OWASP Top 10 CI/CD Security Risks: the pull_request_target and script-injection issues are forms of Poisoned Pipeline Execution (CICD-SEC-4), unpinned third-party actions fall under Dependency Chain Abuse (CICD-SEC-3), and an overscoped GITHUB_TOKEN is Inadequate Identity and Access Management (CICD-SEC-2). None of this is specific to AI-generated workflows — the OWASP list predates widespread use of coding assistants — but an assistant that has no visibility into your specific threat model will reliably reproduce the convenient, insecure default rather than the hardened one, on all three categories at once, in a single generated file.

The same convenient-default problem shows up in other AI-generated configuration, not just CI/CD — see why AI coding assistants keep suggesting deprecated APIs for a related pattern in library code, and blocking dangerous bash commands with a Claude Code PreToolUse hook if you want to catch risky shell commands before an assistant runs them locally, not just before they ship in a workflow file.

A Five-Line Prompt Addition That Catches Most of This Up Front

Asking an assistant to generate a workflow “the usual way” reliably produces the convenient defaults above. Appending a short, specific instruction changes that: “Pin every third-party action to a full commit SHA, not a tag. Add an explicit permissions block scoped to read-only unless a job needs more. Do not use pull_request_target unless I’ve confirmed it’s required. Pass any user-controlled text (titles, branch names) through an environment variable before using it in a run step.” This doesn’t replace the checklist above — a human still has to verify each item landed correctly — but it shifts the starting point from the convenient default to the hardened one, which is a meaningfully easier thing to review than a complete rewrite. The same idea, framed more generally, is behind writing a Copilot instructions file your team will actually follow: a standing, repository-level instruction catches this class of issue before it reaches a single pull request at all.

Quick Reference

  • Trigger: no pull_request_target combined with checking out the PR’s own untrusted branch.
  • Shell steps: no PR title, issue title, or branch name interpolated directly into a run: command.
  • Third-party actions: every uses: line pinned to a full commit SHA or covered by a lockfile, not a movable tag.
  • Token scope: an explicit permissions: block present, defaulting to read-only.
  • Runners: no self-hosted runner reachable from a public repository’s fork-based pull requests.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top