Security Notes
CI/CD & Supply Chain

Build Platform Security: Running Other People's CI Jobs

19 min read 9 sections 8 model answers verified 2026-10 version 1 · updated 2026-10-15

Last verified2026-10 Also see: GitHub & CI/CD security (workflows, runners, pinning) · Container runtimes and sandboxing · OWASP CI/CD risks · Dependency management


Start here: the basics in plain words

Most security pages about CI look at it from the customer's side: "how do I write a safe workflow?" This page looks from the other side. You run the build platform: the machines that run thousands of strangers' CI jobs every hour. Hosted CI services, "faster runners" vendors, remote build services and cloud dev environments all sell this.

Every job a customer sends you is arbitrary code you didn't write and can't review. It might be a normal npm test, or it might be a crypto-miner, an attempt to break out of its sandbox, or a probe for the next customer's secrets. The job must be fast, it must look like a normal Linux or macOS machine with root access, and it must not be able to touch anything outside itself.

Tenant

One customer: an organisation whose jobs, secrets, caches and logs must never be visible to another.

Runner / job machine

The machine (usually a VM) that runs one job, then is thrown away.

Sandbox

The wall around the job: a container, gVisor, a microVM or a full VM. Its strength decides what one escape is worth.

Build cache

Saved results of earlier work (downloaded dependencies, compiled objects, Docker layers) so the next build is faster.

Remote builder

A shared service that runs builds (e.g. BuildKit for Docker images, Bazel remote execution) for many jobs.

Control plane

The platform's own systems that schedule jobs, hand out credentials and store logs. It holds every tenant's data at once.

Three ideas carry the whole page:

The job is the attacker.

Design as if every job is hostile, because some are.

Everything that outlives the job is a path between tenants.

Caches, logs, artifacts, disks, warm VMs and remote builders all survive the job.

The control plane is the jackpot.

Breaking one sandbox gives one host. Breaking the platform's credential store gives every customer's secrets.

Memory hook

"fresh machine in, nothing out but what you meant." Each job starts from a clean image and leaves only what you explicitly keep: the log, the artifacts, the cache entry. Everything else is destroyed.

🎯

On the job: reading a company's public GitHub before an interview. A vendor's public repositories tell you what they worry about. Forks of gvisor or a virtualisation library mean sandboxing untrusted code. Their own GitHub Actions mean customers run their code with customer secrets. A "breakpoint" or SSH tool means interactive access to jobs. A cache action means cross-run state. Each maps to a section below. Ask in the interview which of these keeps them up at night.


Isolating Untrusted Builds

What is this?Isolation is the wall between one customer's job and everything else: the host, the platform and other customers' jobs running on the same hardware.

Why it mattersCI jobs expect root access, Docker, nested containers and full network access. That makes them far more dangerous than a typical web workload. A shared-kernel container gives the job the whole Linux kernel as attack surface. One kernel bug then means every job on that host.

How it worksA job's lifecycle on a well-built platform:

  1. Job requestedservercustomer's CI system asks for a runner
    • Scheduler picks a host; never mixes tenants it doesn't have to
  2. Boot a fresh VM from a golden imageos~1 second for a microVM
    • Read-only base image + empty writable layer; no state from earlier jobs
  3. Inject short-lived identitytlsjob-scoped only
    • A token that registers this one runner for this one job, then expires
  4. Customer code runs as root inside the VMattackthe untrusted part
    • Can do anything inside; should reach nothing outside but allowed network
  5. Collect outputsserverlogs, artifacts, cache entries
    • Stored per tenant, scanned for size and type, not trusted on restore
  6. Destroy and wipedefendalways, even on failure
    • VM discarded; disk blocks wiped or crypto-erased before reuse

Which wall to choose

SandboxWhat the job shares with the hostOne escape givesFits
Plain container (runc)The host kernel, directlyThe host and every job on itTrusted code only
gVisor (runsc)A user-space kernel filters syscalls firstMuch harder; gVisor itself must fall firstUntrusted code that doesn't need full VM features
MicroVM (Firecracker, Cloud Hypervisor, Kata)A tiny hypervisor with a few virtual devicesA hypervisor bug: rare, high valueHostile multi-tenant CI: the common choice
Full VM per jobA full hypervisorSame as above, larger device surfacemacOS, Windows, GPU jobs
Dedicated host per tenantNothing with other tenantsOnly that tenant's own hostsLarge customers, regulated work

The microVM's advantage is its tiny device model. Classic hypervisor escapes went through emulated hardware nobody needed. VENOM (CVE-2015-3456) was a bug in QEMU's emulated floppy disk controller. Firecracker emulates only a few devices (network, block, serial, a keyboard controller used for reset), so there is little to attack.

macOS is differentApple's licence allows at most two extra macOS virtual machines per Mac, on Apple hardware only. Cloud providers also lease Macs for at least 24 hours (that's why AWS EC2 Mac hosts have a 24-hour minimum). So macOS runners can't be packed densely like Linux ones. VMs run on Apple's Virtualization framework, and a whole physical Mac is often shared by only two jobs at a time, or dedicated to one customer. Wiping and re-imaging a Mac host is part of the security boundary.

What still crosses a VM wall

CPU side channels

Spectre and Meltdown (2018) showed code can read data from another process or VM through CPU caches and speculative execution. Platforms with hostile tenants keep current microcode and kernel mitigations. They often also avoid placing two tenants on sibling hyper-threads of one core.

Disk remanence

A reused disk or NVMe namespace may still hold the last tenant's blocks. Wipe it, or use self-encrypting drives and crypto-erase (destroy the key) between tenants.

The network

A job VM that can reach the host's management interface, other VMs, or the cloud metadata service at 169.254.169.254 can steal the platform's credentials.

Noisy neighbours

One tenant saturating disk or network can slow others down. That's a fairness problem, and sometimes a timing side channel.

📰

Real incident — runc (CVE-2019-5736) and "Leaky Vessels" (CVE-2024-21626). In 2019, a malicious container could overwrite the host's own runc binary through /proc/self/exe and get root on the host the next time runc ran. In January 2024, runc leaked an open file descriptor to the host filesystem into new containers. A Dockerfile with WORKDIR /proc/self/fd/7 could then reach host files during an ordinary docker build. Lesson: for a platform running strangers' builds, a container escape bug is a cross-customer breach. That's why hostile CI belongs in a VM, with containers only inside the VM.

🎯

On the job. Questions to ask about any build fleet: Is there one VM per job, or are containers shared on a long-lived host? Is any VM or host reused between tenants, and how is it wiped? From inside a job, can you reach 169.254.169.254, the host's IP, or another job's IP? Run curl -m 2 http://169.254.169.254/ from a test job. It should time out.


Docker Builds and BuildKit

What is this?BuildKit is the engine behind docker build and docker buildx. Hosted platforms often run remote BuildKit builders: a powerful machine with a warm layer cache, shared across many of a customer's jobs.

Why it mattersA Dockerfile is a program, and building it runs every RUN line. A remote builder runs many of those programs, so it is a multi-tenant sandbox too. Secrets passed to builds the wrong way get baked into image layers and shipped to everyone who pulls the image.

How it worksThe three ways a secret ends up in a build, and which one is safe:

dockerfile
# ✗ ARG / ENV: the value is recorded in image metadata or layers
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci

# ✗ COPY then delete: the file still exists in the earlier layer
COPY .npmrc /root/.npmrc
RUN npm ci && rm /root/.npmrc

# ✓ Secret mount: the file exists only while this RUN step runs, never in a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
bash
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t app .

In practiceAnyone who can pull the image can read what was baked in:

text
$ docker history --no-trunc registry.example.com/app:1.4
CREATED BY
RUN |1 NPM_TOKEN=npm_9fQx…aZ /bin/sh -c echo "//registry.npmjs.org/…   ← the token, in plain text
$ docker save registry.example.com/app:1.4 | tar -x && tar -tf <layer>.tar | grep npmrc
root/.npmrc                                                               ← "deleted" file, still in its layer

Security angle

Entitlements

BuildKit can grant a build extra powers: --allow network.host and --allow security.insecure (a privileged build step). A multi-tenant builder should refuse both. The daemon only allows them if started with --allow-insecure-entitlement.

Shared builders

One builder per tenant, never shared across tenants. Its layer cache is a cross-build channel (see next section).

Base images

FROM node:22 is a dependency like any other: pin by digest (FROM node:22@sha256:…) for reproducible, reviewable builds.

📰

Real incident — BuildKit "Leaky Vessels" (CVE-2024-23651, CVE-2024-23652, CVE-2024-23653). Disclosed alongside the runc bug in January 2024. They were a race in cache mounts, a teardown step that could delete files on the host, and a privilege check that could be skipped through BuildKit's API. All three let a malicious Dockerfile or build request act outside its sandbox, and were fixed in BuildKit 0.12.5. Lesson: the build engine is part of the attack surface, not just the code being built.


Build Caches and Remote Execution

What is this?A cache stores the output of earlier work so the next build can skip it. Examples are node_modules, compiled Go packages, Docker layers, or a whole Bazel action. A remote cache shares those outputs between machines and runs.

Why it mattersA cache entry is output someone else's build produced, which your build will use without rebuilding it. If an attacker can write one entry that a trusted build later restores, their code ends up in a trusted release. The trusted build never ran their pull request.

How it worksTwo kinds of cache, very different trust:

Content-addressed storage (CAS)

Stores a blob under the hash of its own content. If a blob doesn't match its name, the reader notices. Safe to share for reading.

Action cache / keyed cache

Maps "these inputs" → "this output". Nothing proves the output really came from those inputs. Whoever can write it decides what everyone reads.

GitHub Actions caches are keyed caches with branch scoping:

  • A run can restore caches from its own branch and from the default branch.
  • A pull request's caches are scoped to that PR and are not seen by main.
  • So the dangerous path is any attacker-controlled code running in a default-branch context. For example, a pull_request_target workflow that runs PR code, or an injection into a workflow on main. That code can save a poisoned cache entry, which every later run restores, including release runs.

In practiceBazel's standard setup: CI writes the remote cache, developers only read it.

text
# .bazelrc on developer laptops
build --remote_cache=grpcs://cache.example.com
build --remote_upload_local_results=false        ← laptops read but never write

# CI (trusted, post-merge builds only)
build --remote_cache=grpcs://cache.example.com
build --remote_upload_local_results=true

Security angle: rules for a platform that hosts caches.

Namespace every key by tenant

Enforce it server-side from the job's identity, not from a key the job chooses.

Separate trust levels inside one tenant

PR builds read from the trusted cache but write only to their own scope. Only protected-branch builds write the shared one.

Release builds skip caches entirely

Or they restore only content-addressed blobs verified by hash.

Treat restored cache content like downloaded code

It is unpacked into the workspace and executed by the next build.

📰

Real incident — Ultralytics (December 2024). A pull_request_target injection let an attacker run code in the repository's trusted context. Investigators found the attacker used that foothold to poison the GitHub Actions cache. The separate release workflow restored the poisoned cache, and the published ultralytics package contained a crypto-miner. Lesson: the release workflow had no bug of its own; the cache was the bridge.

🎯

On the job. In a design review of any cache, ask: who can write it, who reads it, and is the key chosen by the client or derived by the server? "Any job can write any key" is the finding.


Interactive Access to CI Jobs

What is this?Some failures only happen on CI, so tools let a developer pause a job and SSH into it. Examples are a "breakpoint" step that holds the job when a test fails, or tmate-based actions that print a connection string into the log.

Why it mattersThe paused job still holds everything the job had: the checked-out code, the GITHUB_TOKEN, cloud credentials from OIDC, decrypted secrets in environment variables. An interactive shell there is a shell with all of the job's secrets.

How it works and where it goes wrong.

  1. Test fails, job pausesserverbreakpoint step
    • Job time is extended; secrets stay loaded in memory and env
  2. Relay opens SSH endpointnetworkjob VM dials out
    • Connection string printed in the log, or sent to the user
  3. Who can connect?attackthe whole question
    • If the string is in a public repo's log, anyone who reads the log can
  4. Restrict and expiredefendsafe version
    • Only the person who triggered the run, using their registered SSH keys; hard time limit; session logged

In practiceThe common tmate action has a setting that decides this:

yaml
- uses: mxschmitt/action-tmate@<full-sha>
  if: failure()
  with:
    limit-access-to-actor: true     # ← only the SSH keys of the user who triggered the run; without it, anyone with the log line can connect

Security angle

Never on fork PRs or public logs without access limits

A fork PR author would get a shell, so the feature must check who triggered the run.

Bind to identity, not to a secret URL

Use the triggering user's registered SSH keys or SSO, not "whoever has the link".

Time-box and audit

Set a maximum pause, record the session, and show it in the job log so the team sees someone connected.

Assume secrets are seen

If someone debugged a job that had production credentials, those credentials have been read by a human. Prefer debugging jobs that don't hold them.


Secrets and Identity on Hosted Runners

What is this?How credentials reach a job, and what the platform itself stores.

Why it mattersA CI platform holds every customer's deploy keys, cloud credentials and registry tokens in one place. That makes it one of the most valuable targets in a software supply chain.

How it works

Prefer short-lived, job-scoped identity

The job gets a signed OIDC token ("I am run 812 of repo acme/app on branch main"). Each customer's cloud exchanges it for credentials that expire in minutes. Nothing long-lived is stored by the platform. See OIDC in CI.

Runner registration tokens

These let a machine join as a runner. They must be one-time and short-lived, or an attacker registers their own runner and receives the customer's jobs.

The platform's own cloud credentials

Job VMs must not be able to reach them. Block the metadata service from the job network. On AWS, require IMDSv2 with a hop limit of 1, so traffic forwarded through an extra network hop can't reach it.

Egress control

Limit where a job can send data. Customers increasingly want an allow-list: a malicious dependency that can't reach its server can't exfiltrate.

📰

Real incident — CircleCI (January 2023). Malware on a CircleCI engineer's laptop stole a single sign-on session that was already past two-factor authentication. With it, the attacker reached production systems and took customer environment variables, tokens and keys. They were encrypted at rest, but the attacker also took the encryption keys from a running process. CircleCI told every customer to rotate every secret stored on the platform. Lesson: a CI platform concentrates its customers' secrets. Customers should prefer OIDC so there is less to steal, and the platform's staff access is part of every customer's attack surface.

🎯

On the job. For a platform: who among staff can read customer secrets, from which devices, with what session lifetime? For a customer: list every long-lived secret stored in your CI settings, and replace each one with OIDC where the target supports it.


Shipping What You Build: Actions, CLIs and Install Scripts

What is this?CI vendors also publish software that runs inside their customers' pipelines: GitHub Actions, CLIs, SDKs, Homebrew taps, curl | sh installers.

Why it mattersCustomers run that code with their own secrets in reach. A compromised release of one popular action is a breach of every repository that uses it. It's the same pattern as the Codecov Bash Uploader (2021) and tj-actions (2025) incidents.

How to release safely

  1. Protected release pathdefendwho can publish
    • Releases only from a protected branch, by a workflow with required reviewers; no personal tokens
  2. Signed provenancedefendprove where it came from
    • SLSA provenance / GitHub artifact attestations: "built from commit X by workflow Y"
  3. Immutable versionsdefendnothing moves after release
    • Immutable releases and tags; customers pin by full commit SHA
  4. Verifiable installdefendcustomers can check
    • Checksums and signatures published; install scripts verify them before running anything

In practiceWhat a customer can check before trusting a downloaded binary:

text
$ gh attestation verify ./vendor-cli_linux_amd64 --repo vendor/cli
✓ Verification succeeded!
  sha256:4c1e…  was attested by:
  REPO        PREDICATE_TYPE                   WORKFLOW
  vendor/cli  https://slsa.dev/provenance/v1   .github/workflows/release.yml@refs/tags/v2.3.0   ← built by the release workflow, from this tag
🎯

On the job. Review the vendor's own release workflow like an attacker. Can a PR from a fork reach the job that publishes? Is the Homebrew tap repository protected as strictly as the main one? Does the install script download over HTTPS and check a signature, or just pipe a URL into a shell?


Dev Environments and Preview URLs

What is this?Platforms also run cloud development environments: a browser IDE (e.g. VS Code running server-side) on a VM per developer. They also give each pull request or port a preview URL like pr-1234.acme.preview-host.dev.

Why it mattersA browser IDE is a full shell with the developer's Git credentials. A preview URL exposes an unfinished app with debug settings to the internet. Both turn a browser session into code execution.

Security angle

Authenticate every exposed port

A dev server bound to 0.0.0.0 and forwarded publicly is open to anyone with the link. Make private-by-default the platform behaviour, not a user setting.

Separate domain for customer content

If tenant previews live on subdomains of the platform's main domain, a malicious preview can set cookies for the parent domain and attack the platform's own login session. Put tenant content on a separate registrable domain listed on the Public Suffix List, as GitHub does with github.io for Pages. Browsers then treat each tenant subdomain as its own site.

Certificates leak hostnames

Every publicly trusted certificate is published in Certificate Transparency logs. Issuing one per preview publishes pr-1234.customer-name.… to the world. A wildcard certificate (DNS-01 validation) avoids both the leak and Let's Encrypt's rate limit of 50 certificates per registered domain per week.

Long-lived VMs drift

Dev environments live for days, unlike CI jobs. Patch them, cap their lifetime, and keep their tokens scoped to that developer.


Interview Questions

Q
How would you isolate customer CI jobs that run on shared hardware?
Model answer

I'd run each job in its own microVM, such as Firecracker, booted fresh from a read-only image and destroyed afterwards, with containers allowed only inside that VM. Plain containers share the host kernel, and bugs like the 2019 runc escape (CVE-2019-5736) turn one malicious job into every job on the host. Then I'd close the paths that outlive the job: per-tenant caches and logs, disks wiped or crypto-erased before reuse, and no network route from the job to the metadata service or the host.

Q
What can still leak between two tenants even with one VM per job?
Model answer

Anything shared below or around the VM: CPU side channels like Spectre if two tenants share a core, leftover blocks on a reused disk, and the network if the job can reach the host or other VMs. Most real leaks are higher up, though, in what the platform keeps after the job: caches, artifacts, logs and remote builders. Those need tenant namespacing enforced server-side from the job's identity.

Q
How does build cache poisoning work, and how do you prevent it?
Model answer

A keyed cache says "for these inputs, use this output", and nothing proves the output came from those inputs, so whoever can write an entry decides what later builds use. In Ultralytics in 2024, attacker code running in a trusted context poisoned the GitHub Actions cache, and the separate release workflow restored it and published a crypto-miner. The fixes are server-enforced keys per tenant and trust level, PR builds that can read but not write the shared cache, and release builds that skip caches entirely.

Q
How do you pass a secret into a Docker build without leaking it?
Model answer

Use a BuildKit secret mount, RUN --mount=type=secret, so the file exists only while that one step runs and is never written into a layer. Passing it as an ARG or ENV records it in the image history, and copying a file then deleting it leaves it in the earlier layer, so anyone who pulls the image can read it with docker history or by unpacking the layers. If one has already leaked into a published image, rotate it; deleting the tag doesn't help.

Q
A customer wants to SSH into a failed CI job. What are the risks, and how would you design it?
Model answer

The paused job still holds its token, cloud credentials and secrets, so the SSH session is effectively those secrets. The classic mistake is printing a connection string into a public log, where anyone can use it; the tmate action's limit-access-to-actor setting exists for exactly that. I'd allow it only for the user who triggered the run, authenticated with their registered keys or SSO, never for fork PRs, with a hard time limit and the session recorded in the job log.

Q
What does the CircleCI 2023 breach teach a CI platform?
Model answer

A CI platform concentrates every customer's secrets, so its own staff access becomes everyone's attack surface. An engineer's laptop malware stole an SSO session past two-factor authentication, and the attacker took customer secrets plus the keys to decrypt them from a running process. Platforms should keep staff sessions short and device-bound, and push customers towards OIDC so there is little long-lived to steal. Customers should treat stored CI secrets as something they may one day have to rotate all at once.

Q
You publish a GitHub Action used by thousands of repositories. How do you secure releases?
Model answer

Releases come only from a protected branch, through a workflow with required reviewers and no personal tokens. Each release has signed provenance saying which commit and workflow built it, and immutable releases so a tag can't be moved. tj-actions in 2025 showed the failure mode: one stolen token moved every version tag to malicious code. I'd also tell customers to pin by full commit SHA, so even a compromised tag can't reach them.

Q
Why do platforms put preview environments on a separate domain?
Model answer

Because a subdomain can set cookies for its parent domain, so a malicious tenant preview at x.platform.com could plant cookies that attack the platform's own login. Putting tenant content on a separate domain listed on the Public Suffix List, as GitHub does with github.io, makes browsers treat each tenant as a different site. A related detail: per-preview certificates publish every hostname to Certificate Transparency logs, which is one reason to use a wildcard certificate.