The release job has no stored cloud key. It asks GitHub for an identity token, exchanges that token for a five-minute production credential, deploys, and lets the credential expire.
A compromised dependency runs in the same job. It can use the same identity request interface and deploy while the credential is live. Five minutes is short for an attacker waiting outside the build. It is generous for code which is already executing inside it.
Replacing a long-lived secret with OpenID Connect removes an important class of exposure. There is no cloud key sitting in repository secrets, and a copied session cannot be replayed indefinitely. It does not change what the job can do during those five minutes.
That distinction is the useful starting point for CI credentials:
- lifetime controls how long authority remains reusable;
- permissions control which operations it can perform;
- resource scope controls where those operations apply;
- claim policy controls which job may receive the authority; and
- execution boundaries control which code gets a chance to use it.
Calling all five “least privilege” loses the failure you need to reason about.
The dangerous unit is the credentialed process
A workflow file makes credentials look like values passed to selected steps. That is not the complete runtime boundary.
GitHub creates a GITHUB_TOKEN for each job. An action can use
the token through github.token even when the workflow author did not pass it
as an input. Job-level permissions still constrain it, but
omitting ${{ secrets.GITHUB_TOKEN }} from a step is not isolation.
OIDC has a similar, narrower interface. Granting id-token: write lets code in
the job request a GitHub identity token. The permission does not itself grant a
cloud deployment or package publication. The external provider evaluates the
token’s claims, decides whether they match its trust policy, and issues a role
with its own operations and resources.
That separation matters in both directions. id-token: write is not cloud
write access by itself. Once an accepted token can mint a production role,
however, every executable step in that job can try to request it. This includes
project scripts, package-manager hooks, test tools, build dependencies, and
actions—not only the final deployment command.
This is the execution seam left open by a dependency lock and the credential edge inside the larger artifact authority map.
A credential review therefore starts with a process question:
If any executable input in this job becomes hostile, which named operations can it complete before the job ends?
The answer is not “it has OIDC” or “the token lasts five minutes.” It is a list such as “write this repository,” “publish any package,” or “deploy production.”
Short lifetime closes one attack window
The SEC-03 fixture is a synthetic capability model. It does not run on GitHub or contact a cloud provider. It gives seven jobs explicit token permissions, secrets, OIDC claims, trust conditions, roles, resources, artifact checks, and attempted operations. The evaluator records what is allowed during the job and what can still be replayed after it.
The first case gives untrusted pull-request code a repository write token and a long-lived release secret. During the job, that process can:
- write the source repository;
- publish both modeled package namespaces; and
- deploy the production environment.
After the job, a copied release secret can still publish and deploy. The job’s automatic repository token has expired, so repository writes do not survive. “Broad credential” was never one property: two authorities with different lifetimes happened to occupy the same process.
The second case replaces the release secret with OIDC. Its repository, revision, environment, workflow, and audience claims match the external trust conditions, so the role can deploy production. Untrusted code in the job can request that role and deploy immediately. It cannot replay the modeled session after the job.
That is a real reduction. It is also not enough to accept the workflow. The immediate production operation remains.
A smaller radius can still contain bad work
The third case narrows authority to one operation on one resource:
registry:quote-tool:publish. The compromised dependency cannot write the
repository, publish other-tool, or deploy production.
It can publish a malicious quote-tool release.
Resource and operation scope have worked exactly as intended. They reduce the number of things which need recovery after compromise. They do not make the remaining publication desirable.
This is why a permission review should preserve names instead of producing a score. “One allowed operation” can mean reading metadata or replacing the package thousands of consumers install. Cardinality does not describe impact.
For each credential boundary, record at least:
| Property | Evidence | Residual failure |
|---|---|---|
| Executable input | Origin and digest of actions, dependencies, scripts, and artifact consumers | Reviewed code can still be malicious or defective |
| Repository authority | Job-level token permissions | An allowed write can still damage source or settings |
| OIDC request | id-token: write result | Any code in the job can attempt the request |
| External trust | Issuer, audience, repository, revision, workflow, and environment conditions | Matching claims can come from a compromised step |
| Role scope | Named operations and resources | The complete allowed namespace remains exposed |
| Lifetime | Issue, expiry, and later replay result | Immediate use remains possible |
| Runner reach | Filesystem, credential interfaces, and network destinations | Allowed egress can carry stolen data |
Each row needs its own enforcement point, failure action, recovery owner, and condition for reconsideration. A single “CI security” result cannot tell an operator whether to revoke a publisher, repair a repository, replace a package version, or investigate a deployment.
Untrusted verification should remain unprivileged
GitHub already treats ordinary fork pull requests as a distinct boundary. Its
event documentation says secrets other than GITHUB_TOKEN are not
passed to workflows triggered from forks, and the token is read-only. Private
repository settings can change fork behavior, so a workflow review must inspect
the actual organization and repository policy rather than rely on folklore.
The fixture’s fourth case makes the intended decision explicit. The pull- request job can read source and execute the contributor’s tests and dependencies. It cannot write the repository, publish either package, deploy, or request an OIDC token.
This job can answer a verification question. It cannot make a release decision.
Privileged triggers deserve extra care. GitHub’s secure-use reference
warns against checking out untrusted pull-request content in a
pull_request_target or privileged workflow_run context. Those triggers are
not vulnerabilities by name. The dangerous composition is trusted authority
plus attacker-influenced code or data which the privileged job executes or
accepts.
Approval does not rewrite that composition. Approving a workflow run or protected environment can establish that a person accepted a deployment decision. It does not inspect every dependency which will execute after the approval.
Splitting jobs moves trust to the artifact
Separating verification from publication is necessary, but the boundary between the jobs can be just as weak as the job it replaced.
In the fifth fixture case, an unprivileged job uploads attacker-selected bytes
as quote-tool.tgz. A privileged follow-up job checks only the filename and
publishes the archive. The split protected the credential from the first job.
It did not protect the publication from the first job’s output.
A filename is routing information, not artifact identity.
GitHub’s secure-use guidance tells privileged workflow_run jobs to treat
artifacts from other workflows with caution. Artifact attestations
can bind a digest to claims about how and where an artifact was built. The
consumer still needs a policy: which revision, workflow, builder identity, and
digest is acceptable for this publication?
An attacker-controlled artifact cannot answer that question by including its own expected digest. The expected value must arrive through a trusted decision path independent of the bytes being checked.
Put the credential after the evidence
The sixth fixture case uses three boundaries:
- A pull-request job executes untrusted code with source-read permission and no publication or OIDC authority.
- After a protected merge, a builder uses the trusted main revision without a publication credential. It records the artifact digest, revision, and builder identity.
- A minimal publisher waits for environment approval, verifies that evidence
against a trusted build record, requests a short-lived identity, and can
publish only
quote-tool.
The external OIDC policy checks repository, revision, workflow, environment, and audience. GitHub’s OIDC reference requires at least one trust condition so an untrusted repository cannot request cloud authority. One is a minimum, not a design target. Each stable claim which removes an unintended caller should be considered, while remembering that brittle claim policy can also stop legitimate releases.
The publisher receives no repository write or production deployment operation. Its credential expires after the job. The artifact’s expected digest comes from the trusted build record rather than the archive it is validating.
The representative workflow also pins actions to full commit identifiers. GitHub’s hardening guidance explains the useful property: a tag cannot silently move the action to different code. A full pin says which action code was selected. It does not restrict that code’s access to the job token, OIDC interface, filesystem, or network.
The final case compromises the minimal publisher after artifact validation. It
can replace the bytes and publish a bad quote-tool version. It still cannot
write source, publish other-tool, deploy production, or reuse the expired
session later.
That remaining package publication is the honest blast radius. Recovery means revoking the publisher trust, withdrawing or replacing the affected version, identifying consumers, and repairing their selected artifact. “Rotate CI credentials” would miss both the resource and the consequence.
Isolation may deserve its own service—or a human
A split CI workflow is not the only credible design.
A dedicated release service can accept an immutable artifact identity and keep general build code away from publication authority. This can be attractive when many repositories share a release policy. It also creates a service which must be patched, isolated, available, audited, and protected from malicious release requests. If nobody can own those obligations, the extra boundary is theatre.
For rare releases, manual publication from a protected workstation may be smaller and easier to inspect. It shifts risk to workstation security, human credentials, reproducibility, and operator error. Increasing release frequency or coordination delay can reverse that choice.
The fixture does not choose between those operating models. It retains fourteen
independent control decisions, twenty-six rejected semantic counterfactuals,
nine official GitHub source responses, nineteen source hashes, a zero-finding
dependency audit, and a clean export of commit
ce5689664f916113092669d90f87edea388b1e39. It does not reproduce GitHub’s
runner boundary, mint real identities, inspect Brian’s repository settings, or
prove a production cloud policy.
When reviewing a CI credential, ask about its lifetime. Then keep going. Name the code which can reach it, the claims which can mint it, the exact operations and resources it unlocks, the artifact evidence checked before minting, and the damage which remains if the smallest privileged step turns hostile.
Short-lived credentials make cleanup smaller. A small blast radius comes from deciding what the live process is allowed to hit.