Values in a CI build
Where penv delivers values in your CI job, the four limits penv cannot remove, our credential lifetimes, trigger rules, SHA pinning and single audience.
Per-platform setup: connect a platform.
Pin actions by commit SHA
steps:
- uses: owner/action@<full-40-character-commit-sha> # not @v1, not @mainPin every third-party action in your workflows to a 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.
Request one audience
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=YOUR_WORKSPACE_ID"The audience is your workspace id, so a token minted for your workspace cannot authenticate at another. We refuse a token requested for two audiences at every exchange rather than pick from the list. We match subjects exactly, with no wildcards.
Keep tokens inside one step
Keep the OIDC token and the credential we return inside one step and one process. A token in a step output or the job environment is readable by every later step and debug log.
Delivery channels
penv run puts values in one child process. Every other runner channel is closed:
| Channel | Reason closed |
|---|---|
GITHUB_ENV | Code execution primitive through LD_PRELOAD and NODE_OPTIONS; readable by every later step. |
Job-wide env: | Same reach as GITHUB_ENV. |
| Step or job outputs | Read by later steps. The runner drops a secret-tainted job output rather than mask it. |
argv | ps x -w reads it across the whole job for the life of the process. |
GITHUB_WORKSPACE | Uploaded by artifact steps, cached by cache steps. |
Where a tool cannot read a variable, the penv-cloud action's files input (not yet published) writes a file under $RUNNER_TEMP at mode 0600 and removes it before the step ends, even on failure.
Never expand ${{ }} into run: text: the runner echoes the expanded script body before executing it. See penv run.
Limits penv cannot remove
| Limit | Detail |
|---|---|
| Co-location in a job is disclosure | A malicious step in the same job defeats log masking, the variable-or-file choice, and step scoping. CVE-2025-30066's payload read Runner.Worker process memory, lifted the runner's secret table, and double-base64'd it past the masker (as does Cacheract). Not closeable. |
| Revocation is best effort | A REST force-cancel skips always(); a killed runner runs no post: step; GitHub terminates everything five minutes after a cancellation; nothing survives SIGKILL. The credential's expiry is the guarantee. |
| Non-ephemeral self-hosted runners | State persists between jobs and GitHub guarantees nothing there. --ephemeral only de-registers the runner. Supported, never called secure. |
| Cache restore | Keep delivered values out of any job that restores a cache. Cache poisoning runs attacker code in the job without the attacker being in it. |
Masking
Masking is a courtesy, not a control. The penv-cloud action registers the whole value, each line (as vault-action has since CVE-2021-32074), and each JSON leaf with the runner's masker. penv run masks its child's output on its own.
| Covered by the runner | Not covered |
|---|---|
| The registered value and its lines | Hex |
| base64 at all three alignments | Case changes |
| URL, JSON, XML, command-line encodings | Double base64, anything derived |
The action warns about a value too short to mask meaningfully.
Credential lifetimes
We fix both lifetimes, identically on every plan.
| Endpoint | Lifetime | Use |
|---|---|---|
POST /api/v1/auth/oidc | 15 minutes | Machine credential placed in PENV_TOKEN. |
POST /api/v1/auth/oidc/delivery | 5 minutes | Build credential that carries values, bound to one run. |
POST /api/v1/auth/revoke | n/a | Credential destroys itself. Authenticated only by that credential, reaches no other row, idempotent so an end-of-job hook never fails a build. |
The delivery credential binds verified claims: repository, workflow ref, executing job's workflow ref, run id, run attempt, actor. It is not per-request proof (a dumped credential carries its run id), but gives you precise audit and dies with the run.
Triggers
| Trigger | Status | Reason |
|---|---|---|
pull_request_target | We refuse it, on both OIDC exchanges | Base-repository permissions against an untrusted head; root of the March 2025 compromise chain; exempt from GitHub's fork permission downgrade. |
pull_request | Allowed | A fork cannot mint a token; id-token is write or nothing. Exception: a private repository whose admin enabled write tokens for pull request workflows. |
workflow_run | Allowed | Definition comes from the default branch. We document handling of untrusted artifacts; we do not block it. |
Audit entries name the run
We stamp the run on the audit entry of every build-credential read, scoping an incident to one execution. The actor stays the machine identity. See the audit log.