skip to content

An outside contributor cannot merge anything to your main branch, but they can open a pull request that triggers CI. What can they still make your pipeline execute, and how do you limit it?

level: seniorimportance: should knowfreq 38%

answer

  1. they own the branch, not the merge
  2. running their tests runs their code
  3. direct versus indirect poisoning
  4. which ref supplies the pipeline definition?
  5. make the untrusted job worth nothing

basics

~20 s

They control the branch the build checks out, so they control the build scripts, test files and configuration the pipeline invokes — and often the pipeline definition itself. Running the branch's tests is running their code.

solid answer

~50 s

This is poisoned pipeline execution, and it comes in two shapes. **Direct**: if the CI system reads the pipeline definition from the contributor's branch, they simply rewrite the build — add a step, dump the environment, exfiltrate anything the job holds. **Indirect**: even when the pipeline definition is read from a trusted ref, the build still runs code from the branch. Build scripts, task runner targets, test files, test-framework configuration that loads plugins, code generators and package lifecycle hooks are all attacker-supplied. "We only run their tests" is not a mitigation; running tests is arbitrary code execution. The limits are structural: the untrusted build gets no credentials worth stealing and a read-only token; the pipeline definition is read from the trusted ref; publishing, signing and deploying live in a separate pipeline that runs only after merge; and a maintainer approves before CI runs on a first-time contribution.

go deeper

for a junior

Recall that CI checks out the contributor's branch and runs what is in it, so opening a pull request is enough to get code running on a build machine.

for a middle

Distinguish direct poisoning — rewriting the pipeline definition on the branch — from indirect poisoning through build scripts, tests, lint plugins and install hooks, and explain why the second survives locking down the first.

for a senior

Design the split: a credential-free validation pipeline for contributions, a separate release pipeline on the merged ref, the definition read from a trusted ref, and no shared workers between the two. Say what each control actually prevents.

for a principal

Own the tradeoff between open contribution and containment — approval gates cost contributor latency, and a single blanket policy across a large estate will be bypassed. Decide where the line sits and how compliance is verified.

## Naming the class Poisoned pipeline execution is the situation where someone who cannot merge code can nevertheless get the build system to execute code of their choosing. It is the defining risk of running CI on contributions, and it is the reason "they can't merge" is not a security answer. ## Direct: the pipeline definition is source code too The pipeline definition lives in the repository, on a branch, next to everything else. If the CI system evaluates the definition from the *branch being tested* — the common default, because that is how a contributor changes their build — then modifying the build is as easy as modifying any other file. The contributor adds a step that prints the environment, uploads it somewhere, or uses the job's token, and the pipeline dutifully runs it because it is doing exactly what pipelines do. The structural answer is to decide, explicitly, which ref the *definition* is read from for untrusted contributions, and to treat pipeline files as protected paths requiring maintainer review — the same way you would treat a deployment manifest. ## Indirect: the build runs their code anyway This is the part teams miss, and the part interviewers probe. Suppose the pipeline definition comes from the trusted ref, so the contributor cannot add steps. The pipeline still checks out their branch and then runs things *in* it. Every one of the following is attacker-controlled content that executes: - **build scripts** — a `Makefile`, a task-runner target, a build file in a language that is itself executable - **package lifecycle hooks** — install scripts that run during dependency installation of the branch's manifest - **the test suite** — arbitrary code by definition; a test can open a socket as easily as it can assert - **test and lint configuration** — configuration files that load plugins from the workspace, so "just running the linter" executes branch code - **code generation and pre-processing steps** the build invokes - **the dependency manifest** — a contributor can add a dependency whose install hook is the payload So the correct mental model is: *any* job that checks out an untrusted ref and does more than read files is executing untrusted code. The interesting question stops being "can they run code?" — assume yes — and becomes "what does the job that runs their code hold?" ## Limiting it **1. Make the untrusted job worth nothing.** The build that validates a contribution should have no deploy credential, no registry push right, no signing key, and a read-only token. If the attacker gets code execution and finds an empty environment, the attack ends there. This single decision does most of the work. **2. Split trusted from untrusted work.** Publishing, signing, deploying and any step touching production belong to a pipeline that runs on the merged, reviewed ref — after review has happened. Do not try to build one pipeline that both validates strangers' code and holds release credentials, gated by conditionals; conditionals are easy to get subtly wrong and easy to trigger accidentally. **3. Read the definition from the trusted ref** for untrusted contributions, and require review on changes to pipeline files. **4. Gate the first run.** Requiring a maintainer to approve before CI runs on an outside contribution converts an automated attack into one that needs a human mistake. It costs contributor latency, which is a real tradeoff worth stating. **5. Beware the privileged-trigger trap.** Most platforms offer a trigger variant that runs in the *base* repository's context — with its secrets — while still relating to the contribution. Those variants exist for legitimate reasons (labelling, commenting) and are safe only if they never check out or execute the contributed code. Checking out the branch under such a trigger recreates the full vulnerability with credentials attached. **6. Do not let one bad run poison the next.** Where the same worker serves many jobs, code execution in an untrusted build can leave state behind for a later privileged build. Ephemeral, per-job workers remove that path; the general isolation design is its own topic, but the pipeline-design consequence — never schedule untrusted contributions onto the same workers as release builds — belongs in this answer. ## What a strong answer sounds like "Assume they get code execution — running their tests grants it, and often they can rewrite the build outright. So I design for it: the contribution pipeline holds nothing, the release pipeline runs only on merged code, the pipeline definition comes from the trusted ref, and the two never share a worker."

  • A team argues that reading the pipeline definition from the base branch fully solves this. What do you tell them?
    It closes the direct path only. The build still checks out the contributor's branch and invokes code from it — build scripts, tests, lint plugins, install hooks. That is indirect poisoning, and it grants the same code execution. Controlling the definition is necessary but the credential-free design of the untrusted job is what actually bounds the damage.
  • Where does the risk go when a job runs the contribution's linter rather than its test suite?
    Nowhere, usually. Modern linters load configuration and plugins from the workspace, so a contributed config can pull in a plugin that executes on startup. The same applies to formatters, code generators and type checkers. Any tool that reads workspace configuration should be assumed to execute workspace code.
  • How do you let a contribution pipeline post results back to the pull request without giving it a write credential?
    Separate the phases. The untrusted job produces output as an artifact and holds nothing; a second, small job running in the trusted context consumes that artifact and posts the comment or status. The privileged job never checks out or executes contributed code — it only reads data — so code execution and credentials never meet.

saying these in an interview costs you the question

  • They cannot merge, so they cannot affect CI
  • We only run their tests, which is not code execution
  • Fork builds are safe because they run on hosted runners
  • A conditional on the branch name keeps release steps from running
  • Reading the pipeline file from main removes the risk entirely

context