CI/CD security

CI/CD security checklist for agent-operated delivery

Secure agent-operated delivery by checking each trust transition, not only the final workflow permissions. Identify who controls the code entering a test job, what that runner can reach, whether an artifact or cache can cross into a privileged job, and which revision a provider identity may ship. A passing test shows one behavior result; a verified source and controlled credential path address different risks. Finish with target-side proof of the deployed revision.

Draw the small-repository trust path

Consider a small repository where an agent proposes a pull request, CI tests it, a build produces a candidate, and a release job deploys a reviewed merge. The critical path is untrusted pull request input to test runner, test output to artifact or cache, artifact to privileged release, release identity to provider, and provider to live target. Draw those transitions before writing a security checklist. Each arrow asks a concrete question: what can the upstream actor control, what can the downstream job execute, and which authority becomes reachable? A list of generic safeguards is less useful than a failed gate tied to one arrow.

Start with the repository's actual events and settings. Public fork pull requests normally receive a constrained workflow context. A private repository can enable policies that send write-capable GITHUB_TOKEN permissions or secrets to fork pull request workflows, so do not infer safety from the event name alone. Read the effective organization and repository fork settings, the workflow's permissions, and the code that will execute. If an agent can author or modify the workflow file, action reference, build script, or test command for a pull request, that is executable input, not merely a diff to be summarized.

A practical threat model names the assets too: repository write permission, package publishing identity, deployment role, internal network services, and evidence records. For each asset, identify the earliest job that can reach it. The boundary is misplaced if a pull request test can read a release secret, if an untrusted report is later executed by a privileged job, or if a cache written by a low-trust trigger becomes executable input to a release. Fix the first unsafe transition, then rerun the path to show the later job still works.

Sources: Managing GitHub Actions settings for a repository — GitHub Docs · Secure use reference — GitHub Docs · Securely using pull_request_target — GitHub Docs

Use five gates that can fail visibly

Gate one is event and revision. Record the event that started the job and the exact source SHA it evaluates. A manually started job or a result from a previous commit cannot silently substitute for the required pull request or merge queue check. Gate two is execution environment. Confirm that untrusted code runs on a runner without release credentials, residual files, or access to sensitive internal services. A job-level permission map does not erase credentials already on a self-hosted machine. The check should fail closed when the intended runner group is unavailable rather than widening to a privileged pool.

Gate three is handoff. Treat reports, archives, and cache entries from pull request jobs as untrusted data until an allowed producer and specific bytes are verified. Gate four is release identity. A trusted job should request only the role needed for its destination, after checking the approved revision and environment. Gate five is delivery proof. Read back the target's live revision or equivalent deployment evidence before declaring success. If an agent cannot obtain a field, the ledger should say unresolved and identify the next operation; a green workflow icon must not fill an evidence gap by assumption.

Illustrative review gates for one repository; adapt the named policy to the actual workflow.
TransitionFailure that should stop itEvidence required before continuing
PR input to test boxFork settings or host expose release authorityEffective event policy, token scope, runner isolation, and source SHA
Test box to artifact/cacheLow-trust output enters a trusted execution pathProducer event, run and artifact identity; cache writer scope
Artifact to releaseRelease executes or publishes bytes from an unapproved producerTrusted rebuild or verified provenance, required checks, exact candidate digest
Release to providerRole trust accepts an unintended repo, ref, workflow, or environmentProvider-side claims and least-privilege role readback
Provider to live targetJob success has no target revision proofTerminal delivery record and observed live revision

These gates are not interchangeable. A passing unit test can show a function returned the expected value for its tested cases; it cannot prove the runner had no secrets. A provenance check can identify a producing workflow; it cannot prove the binary is free of defects. A provider role can be narrow; it cannot prove the target updated. Assign each kind of evidence to the claim it can actually support. This lets a small team make a defensible release decision without building an elaborate security ceremony around every push.

Sources: Secure use reference — GitHub Docs · Dependency caching reference — GitHub Docs · Artifact attestations — GitHub Docs · OpenID Connect reference — GitHub Docs

The untrusted artifact that crossed a privileged boundary

Here is an illustrative failure, not a customer incident. A fork pull request changes a packaging script and uploads a candidate archive from its test job. A separate workflow_run job has a publishing credential. It downloads the archive, extracts it, and runs a setup script from inside to prepare the release. The earlier test job had no intended publishing authority, but the script now executes inside the privileged consumer. The actor who controlled the fork-controlled archive has effectively supplied commands to the release job. GitHub's pull_request_target guidance warns that downloading and executing a fork artifact in a privileged event can create this kind of boundary crossing.

The repair is not to add another archive name check. Keep the fork artifact for diagnostics in a lower-trust context, inspect its contents as data if needed, and rebuild the release candidate from the reviewed merge revision in a trusted workflow. The release job should verify that its own candidate came from the expected repository, workflow, event, and source revision before using it. If an archive must be consumed, validate its paths and digest and avoid executing files supplied by the untrusted producer. A digest match only ties bytes to a recorded digest; it cannot make an attacker-controlled producer trustworthy.

The same crossing can happen through a cache. GitHub gives low-trust triggers read-only access to default-branch caches by default, but an explicit write-capable cache-mode can override that protection. A poisoned dependency cache restored by a privileged job can put executable files on disk before the build starts. Keep trusted cache writers separate, make locked dependency installation authoritative, and never cache credentials. Inspect the effective cache mode on caller and reusable workflows, because a called workflow can request more access unless the caller sets an explicit cap.

Sources: Securely using pull_request_target — GitHub Docs · Secure use reference — GitHub Docs · Dependency caching reference — GitHub Docs

Review executable workflow dependencies as code

A workflow can be compromised without a change to the application source. Third-party actions and reusable workflows execute code inside the pipeline and may see job credentials or shared workspace data. GitHub advises pinning third-party actions to full-length commit SHAs and applying the same principle to reused third-party workflows. Verify each selected SHA belongs to the intended upstream repository rather than a fork; do not invent a 40-character value for a guide or rely on a floating tag when a strict release policy requires immutable input. Review the action's code and permissions before allowing it in a credential-bearing job.

Pinning creates an update responsibility. Record the upstream source, the reviewed commit, and a process for evaluating later security and feature updates. GitHub documents Dependabot support for updates to action and reusable-workflow references using GitHub repository syntax. An automated update pull request is a prompt for review and tests, not permission to ship new workflow code unseen. Also review locally referenced actions and scripts, which the same dependency updater may not manage, and check whether a reusable workflow changes its own credential or cache requirements.

A job's GITHUB_TOKEN permissions should match its actual GitHub API calls, with a separate job for release writes when possible. That is necessary but not sufficient: an action with repository read access can still inspect code or artifacts, and a runner may carry non-GitHub credentials. Check the action's inputs, outbound network needs, and files it can read. If an update adds a new external host or asks for broader permissions, require a concrete purpose before accepting the change.

Sources: Secure use reference — GitHub Docs · Managing GitHub Actions settings for a repository — GitHub Docs

Bind provider identity to the approved release path

OpenID Connect can exchange a GitHub job identity for a provider credential without storing a long-lived cloud key in Actions. The id-token: write permission allows the job to request the identity token; the provider's trust policy and role determine what the job can do. Bind that trust to the intended repository and release context, using supported subject, audience, environment, ref, event, or reusable-workflow claims. GitHub's current OIDC reference includes immutable repository identifiers for newer repositories, so confirm the actual claim format rather than copying an older string into the provider policy.

Keep the role narrow at both ends. A provider that accepts any job in an organization may grant deploy access to an unrelated repository or event even if the workflow has a beautiful approval screen. Conversely, a GitHub environment name is not a provider authorization rule unless the provider checks the corresponding claim and the job truly references that environment. Verify the provider-side trust configuration and effective role permissions from the real account. If the role can publish more packages or deploy more environments than the job needs, reduce that scope before trusting the pipeline.

Provenance helps bind a release candidate to its producer, but only if the consumer verifies the attestation against the expected repository and workflow. GitHub explicitly says an attestation is not a guarantee that the software is secure. Pair provenance with the required checks and the exact source revision, then follow the delivery to the target. The final record should say which identity requested which operation for which candidate and what revision the target reports. Security of the path and correctness of the deployed behavior remain separate claims.

Sources: OpenID Connect reference — GitHub Docs · Artifact attestations — GitHub Docs · Secure use reference — GitHub Docs

Run the checklist on one real delivery path

For a first pass, choose one repository and one actual release workflow. Read the fork policy and workflow triggers, then trace a benign pull request through its test runner and any artifacts or caches. Trace a reviewed merge through the required check, trusted build, provider role, and target readback. At each gate, write down the source SHA, event, job, authority, and evidence link. A missing field identifies a fixable gap. Do not turn the exercise into a count of settings enabled; the objective is to show that low-trust inputs cannot gain high-trust authority and that the version shipped is known.

If a gate fails, stop at that boundary and repair it before broadening the workflow. For example, move release credentials out of a test job, replace execution of a fork artifact with a trusted rebuild, or tighten an OIDC role to the release context. Then run a safe test of the changed path and verify that the required check and delivery evidence still appear for the intended revision. This keeps security controls connected to the operational result: protected code enters through a reviewed gate and reaches a target whose live state can be independently observed.

Sources: Managing GitHub Actions settings for a repository — GitHub Docs · Securely using pull_request_target — GitHub Docs · OpenID Connect reference — GitHub Docs · Artifact attestations — GitHub Docs

Sources and verification

Browse all resources