Values in a CI build
What holds inside someone else's pipeline, and which control is the one actually doing the work.
A pipeline runs on hardware you do not own, beside steps you may not have written. This page says what penv holds there and what it cannot, because your threat model depends on the difference.
Where values go
Values reach one child process and nowhere else. Every other channel a runner offers is closed on purpose.
| Channel | Why it is closed |
|---|---|
GITHUB_ENV | A documented code execution primitive through LD_PRELOAD and NODE_OPTIONS, and readable by every later step. |
Job-wide env: | The same reach, more quietly. |
| Step or job outputs | Read by later steps. A secret-tainted job output is dropped by the runner rather than masked. |
argv | ps x -w reads it across the whole job for as long as the process lives. |
GITHUB_WORKSPACE | Uploaded by the next artifact step and saved by the next cache step. |
A file is written only where a tool cannot read a variable at all. It lands under $RUNNER_TEMP at
mode 0600 and is removed before the step ends, including when the command failed.
${{ }} is never expanded into run: text. The runner echoes the expanded script body before it
executes, so an expression there prints the value it carried.
Four things penv cannot promise
Co-location in a job is disclosure. A malicious step in the same job defeats every measure on this page:
- masking in the log
- the choice between a variable and a file
- scoping a value to one step
CVE-2025-30066's payload read Runner.Worker process memory, lifted the runner's own secret table,
and double-base64'd it past the log masker. Cacheract does the same. This is not a gap being closed.
It is not closeable, and claiming otherwise is what gets people hurt.
Revocation is best effort. The expiry is what is load-bearing. A force-cancel through the REST
API skips even always(), a killed runner runs no post: step because post: is the process
that just died, and the server terminates everything five minutes after a cancellation. Nothing
survives SIGKILL. The end-of-job revoke shortens the usual case, and the credential's lifetime is the
guarantee.
Non-ephemeral self-hosted runners cannot be made safe. State persists between jobs and GitHub
declines to guarantee anything there. --ephemeral only de-registers the runner. penv supports these
runners with that caveat, and never calls them secure.
A job that restores a cache should not carry a delivered value. Cache poisoning is a time-shifting primitive: the attacker's code runs in your job without them ever being in it.
Masking is a courtesy
Masking is not a control, and penv does not sell it as one.
penv registers the whole value and each of its lines separately, which is the shape vault-action
adopted after CVE-2021-32074, plus each leaf of a JSON document rather than the document. The runner
then covers base64 at all three alignments along with URL, JSON, XML and command-line encodings.
| Covered automatically | Not covered |
|---|---|
| The registered value, and its lines | Hex |
| base64 at all three alignments | Case changes |
| URL, JSON, XML and command-line encodings | Double base64, and anything derived |
A value too short for masking to mean anything gets a warning rather than silence.
What actually bounds a credential
Two lifetimes, both fixed by the server. Neither is sold as an upgrade.
| Path | Lives for | What it is for |
|---|---|---|
POST /api/v1/auth/oidc | 15 minutes | An ordinary machine credential your step puts in PENV_TOKEN. |
POST /api/v1/auth/oidc/delivery | 5 minutes | A build credential that carries values, bound to one run. |
The delivery credential is bound to the run from verified claims: the repository, the workflow ref, the ref of the workflow whose job is executing, the run id, the run attempt and the platform actor. That binding is not per-request proof. Someone who dumps the credential dumps the run id with it. What it buys is precise audit and a credential that is worthless once the run is over.
POST /api/v1/auth/revoke lets a credential destroy itself. It is authenticated by nothing but that
credential, it reaches no other row, and it is idempotent, because the caller is an end-of-job hook
that must never turn a green build red.
Which triggers can carry values
pull_request_target is refused outright. It runs with the base repository's permissions against an
untrusted pull request head, which is the root of the March 2025 compromise chain, and it is the one
pull request event exempt from GitHub's fork permission downgrade.
pull_request is allowed, because a fork cannot mint a token at all. id-token is write or nothing,
and fork runs are downgraded. The accepted exception is a private repository whose admin turned on
write tokens for pull request workflows.
workflow_run is allowed, because its definition comes from the default branch. What it does with
untrusted artifacts is documented rather than blocked.
The audience, and why it matters here
Ask for this audience and no other. A token requested for two audiences fails every exchange.
The audience is always your workspace id, so a token minted for one workspace cannot authenticate at another. Subjects are matched exactly, with no wildcards.
Reference any third-party action by its full commit SHA. A tag can be moved to point at other code, which is how CVE-2025-30066 put a credential stealer into thousands of pipelines in March 2025.
Every read from a build names the run
A value read with a build credential stamps the run onto its audit entry, so an incident scopes to one execution instead of to every run since the trust was created. The actor stays the machine identity, as it does everywhere else. See the record of who did what.
Next: who processes what.