All articles

CI/CD security best practices: 7 controls and how to test them

The Chainguard Team
DevSecOpsSoftware Supply Chain
Key Takeaways
  • Limit the paths an untrusted change can take toward a release. Require review for workflow changes, scope tokens to each job, restrict privileged triggers, and isolate runners.

  • Pin actions to full commit SHAs and enforce pinning by policy. Then trace the nested actions and dependencies those pins still resolve.

  • Make failed checks and unverified provenance block promotion. Retest each control’s reject path whenever triggers, runners, package sources, or deployment rules change.

  • Chainguard Actions pins and reviews the nested action and container references inside each hardened action. Your team still owns workflow permissions, release policy, and when to adopt updated references.

A workflow can pin every action, use short-lived cloud credentials, and sign its build evidence, and still ship something it shouldn’t. A pinned action can pull in a dependency that changes underneath it, a cloud role can hand credentials to the wrong branch, and a deployment can accept an image without checking its evidence.

CI/CD security is the set of controls that decides what code a pipeline runs, what access that code receives, and what the pipeline is allowed to release. On March 19, 2026, an attacker force-pushed 76 of 77 version tags in aquasecurity/trivy-action to credential-stealing code (Aqua advisory).

The 7 practices at a glance. Together, they cover the OWASP Top 10 CI/CD Security Risks:

  1. Require review for workflow changes

  2. Keep untrusted jobs away from release credentials

  3. Isolate runners and control what crosses between jobs

  4. Pin and review the code your pipeline actually executes

  5. Make security findings affect release decisions

  6. Verify release artifacts against an explicit policy

  7. Keep security controls working as dependencies change

Each practice here applies to one GitHub Actions workflow that tests a change, builds a container image, and hands it to a release job. For how these controls fit together across a pipeline, see Chainguard’s guide to CI/CD security.

Practice 1: Require review for workflow changes

Without review requirements, anyone who can change a workflow can change the code it runs, the credentials it requests, or where it publishes an artifact. Extend review requirements to workflow definitions and the files they rely on, including build scripts, dependency-source configuration, and deployment policy.

On GitHub, a CODEOWNERS file automatically requests review from the named owners. Requiring their approval is a separate branch-protection setting. Apply the same review rule to the CODEOWNERS file itself.

Confirm the rule works by making a harmless workflow change in a test repository or branch that uses the same rules. An ordinary contributor shouldn’t be able to merge it without the required approval.

Some accounts can still bypass the rule, so record who holds that access and when your policy lets them use it.

Protect the accounts performing those reviews with multi-factor authentication and limited repository permissions. Commit signing adds identity and integrity evidence to the review, but reviewers still judge the code itself. OWASP’s CI/CD Security Cheat Sheet recommends these safeguards as part of source-control protection.

Practice 2: Keep untrusted jobs away from release credentials

A job that runs code from an outside contributor shouldn’t inherit permission to publish a package or deploy a service just because those operations appear in the same workflow. Pull-request tests and production releases need different access levels. Separate that access with these controls:

  • Restrict tokens, secrets, and privileged triggers

  • Test that the release role accepts only the intended branch

Restrict tokens, secrets, and privileged triggers

Give the workflow token minimal default permissions, then grant additional access to the jobs that need it.

permissions: contents: read # workflow default jobs: release: permissions: contents: read id-token: write # only this job can request an OIDC token

Review what triggers each workflow and what code its jobs check out. The riskiest pattern is a pull_request_target workflow that checks out and runs a pull request's code. That code then runs with the base repository's secrets and a write-capable token. Since June 2026, actions/checkout refuses that checkout by default unless the workflow opts out, so search your workflows for opt-outs.

Keep signing, publishing, and deployment credentials away from untrusted execution. Passing a secret to only one step doesn't create a strong isolation boundary if another step can compromise the same job. Log masking helps avoid accidental disclosure in logs, but a masked credential remains usable by code that obtains it.

Prefer OIDC to stored secrets wherever a provider supports it. Keep any long-lived credentials that remain in a secrets manager, or in environment secrets scoped to the release environment with required reviewers. Give each credential an owner and a rotation schedule.

Worked check 1: OIDC accepts the wrong branch

OpenID Connect (OIDC) lets a job exchange an identity token for a short-lived cloud credential. The cloud provider compares the token's claims with a trust policy before granting access. A short lifetime limits how long a credential works. The trust policy determines which jobs can obtain it.

In this workflow, only jobs running from the protected main branch of one designated repository should assume the release role. However, its trust policy also accepts other branches in that repository. A job on a feature branch can receive a short-lived credential because it meets those broader conditions.

To review this case, gather the release role’s effective trust policy and the OIDC claims from jobs running on main and a feature branch. Compare the policy and claims against the intended rule, which permits only the designated repository’s main branch. Include the expected audience, which identifies the token’s intended recipient.

Use the claims and syntax your cloud provider supports. A workflow that uses a deployment environment may need different conditions from this branch-based example.

Narrow the policy to the intended release context and restrict the role to the operations that job needs, since compromised code in an allowed job can still use its credentials.

In an isolated test setup with read-only permissions, the main job should obtain access and the feature-branch job should be denied. Record both authorization results alongside the policy revision.

Practice 3: Isolate runners and control what crosses between jobs

A persistent self-hosted runner can retain files or processes from one job and expose a later job to them. Separate untrusted work from privileged releases and use a clean execution environment between jobs. An ephemeral runner configuration doesn't guarantee that the underlying host has been cleaned.

Follow the handoff from the pull-request job to the release job. Reused hosts, mounted workspaces, caches, and downloaded artifacts can carry untrusted data or code across that separation.

If the release job runs a script from an untrusted artifact, moving the job to a different runner leaves that execution path open. Have the release job run scripts from a protected branch or pinned commit, and keep pull-request artifacts out of that path.

Before promoting build artifacts to a release, verify their digests and build evidence using the checks in Practice 6.

Patch runner software and images, and limit network access to the registries, services, and destinations each job needs. A malicious action can still use whatever access its current job has, even on a fresh runner.

Store job and runner logs outside disposable infrastructure so they survive for investigation, and leave secrets out of them.

Practice 4: Pin and review the code your pipeline actually executes

An action pinned to a commit SHA can run unreviewed code through dependencies that are not pinned. Review every layer your release job executes:

  • Review actions and dependencies referenced by pinned actions

  • Govern the package sources and updates the workflow pulls in

Worked check 2: A pinned action fetches a mutable dependency

A full commit SHA pins an action to a specific revision. GitHub recommends pinning third-party actions to full commit SHAs and checking that the commit belongs to the intended repository.

- uses: example-org/scan-action@v2 # mutable: runs whatever the tag points to - uses: example-org/scan-action@<full-commit-sha> # v2.4.1 # immutable

To enforce pinning across an organization, use GitHub's Actions policy, which can fail any workflow not pinned to a full SHA. Dependabot can keep the pins current.

That pin doesn't fix every external reference the action later resolves. In the Trivy compromise, teams that had pinned trivy-action to a commit from before April 9, 2025 still ran malicious code if their workflows ran during the attack, because that commit pulled in setup-trivy by tag (Aqua advisory).

In this workflow, the release job uses a reviewed composite action at commit A. The composite action references a nested action by the mutable tag @v2. The composite action stays pinned to the same commit, but moving the nested tag changes the code that runs.

Before review Workflow → composite at full commit SHA A → nested at mutable tag @v2 After review Workflow → reviewed composite at full commit SHA B → nested at reviewed full commit SHA C

The composite action moves from A to B because its maintainer pinned the nested action to reviewed commit C. Your workflow receives that repair only after you adopt revision B.

If the upstream action still uses a mutable nested action, pinning the composite again leaves the nested reference open. You’ll need a maintained revision or replacement that pins the nested action, or you’ll maintain that change yourself.

Start with one job that can sign, publish, or deploy. Inspect the action definition and code at the selected revision, then follow the nested actions and container references, reusable workflows, and runtime downloads that job uses. Record what each reference resolves to and where a mutable fetch remains.

Govern package sources and updates

Apply the same review to package registries, namespaces, lockfiles, integrity hashes, and container image digests.

For an internal package, verify that resolution selects the intended private registry. For a public package, check the exact project and publisher to avoid installing an impersonator.

Even the intended project can publish a malicious release, so source selection doesn't replace review of the selected version's contents and behavior.

Known-vulnerability scanning adds another check on the selected version. Its coverage differs from checks for malicious packages and other dependency-chain attacks.

For each release-critical dependency, assign an update owner and review how changes enter the workflow. Include install scripts and changes in requested permissions.

Practice 5: Make security findings affect release decisions

A security check protects a release only when a failed result can stop promotion. A workflow security report can flag a disallowed configuration, but a release can still ship if nothing connects the report to the release decision.

Application scanning and pipeline checks look for different kinds of risks. Source and infrastructure analysis can find problems in what you're building, while workflow checks examine how the automation runs. Secret scanning, dependency analysis, and container scanning add coverage for their respective inputs and artifacts. Select checks based on the risk they can detect.

Then connect required checks to promotion. If your policy prohibits unapproved package sources, for example, a failed source check should block promotion of the affected artifact.

Test that release gate with a harmless input that breaks one required rule every time. Trace the failure through to the promotion decision, including alternate release routes such as manual deployments or separate workflows that can be triggered directly. The test passes only if promotion is blocked. A scanner job that completes despite reporting findings doesn’t meet that bar.

Assign an owner to triage findings, and make any exception an explicit, reviewed decision with an expiry date.

Set the rules around your application’s release risks, and tune noisy checks until teams can address findings without routinely bypassing them.

Practice 6: Verify release artifacts against an explicit policy

Deployment should accept an image only when signed evidence shows that an approved build produced that exact image.

A software bill of materials (SBOM) inventories components. Build provenance records information about how an artifact was produced. Both can be provided as artifact attestations.

A cryptographic signature lets a verifier check that a recognized identity signed a statement or artifact and that nothing has changed it since.

Worked check 3: Deployment accepts an unverified evidence file

In this workflow’s container release, signed provenance exists, but deployment checks only that an evidence file is present. That doesn't establish that the statement belongs to the image being deployed or describes an approved build.

Start with the image's digest and the evidence your release policy requires. Verify the signature and subject digest, then compare the builder, source, and relevant build parameters with your approved expectations. The subject digest must match the image, and the signing identity or root of trust must be one your policy accepts.

Derive the expected values from the approved build configuration. For this container release, that could mean the designated build service, the exact source repository and approved revision, and the production build target.

The release-policy owner records those expectations independently of the evidence that arrives with the image. A statement that names its own builder and source can’t decide whether they’re approved.

The following cases show the expected decisions when valid provenance matching the release policy is mandatory.

Artifact and evidence

Expected release decision

Image digest matches verified provenance, and the builder, source, and required parameters match policy

Pass this provenance check, then apply the remaining release checks

Validly signed provenance names a different image digest

Reject the artifact

Evidence names an unapproved builder or source, or required build parameters differ from policy

Reject the artifact

Required evidence is missing or its signature cannot be verified

Reject the artifact unless an authorized exception explicitly permits release

Confirm the rejection path in your chosen verifier with a harmless test image and mismatched evidence.

Carry the verified digest through promotion and deployment to keep a mutable tag from substituting a different image after verification. The check also needs to govern every route that can deploy the image.

Verified provenance doesn't prove that the source code is safe. It provides authenticated build information to compare with policy. A malicious change in an approved source can still satisfy that policy, which is why code review, dependency controls, and other release checks remain necessary.

Practice 7: Keep security controls working as dependencies change

A control you verified last quarter can stop working the day someone changes a trigger, runner pool, package source, or deployment rule. Give each of those changes an owner, and rerun the affected checks as part of the change.

Reusable workflows and shared policies cut repeated configuration work, provided someone reviews their revisions and exceptions too.

Send GitHub audit logs to the system your security team already monitors. Alert on changes that widen access: edited workflows on protected branches, changes to Actions permissions or policies, new self-hosted runners, and new OIDC trust relationships. CISA's May 2026 alert on CI/CD supply chain campaigns recommends the same audit of workflow files and contributor activity.

Keep the records from the three worked checks together, including the release-role policy, pinned action revisions, verification results, and deployed digest. Link them to the source revision and build.

If a credential is exposed, revoke or rotate it and review activity performed with it. If an input is compromised, those records help identify affected builds and releases that may need rebuilding or withdrawal. CI/CD visibility and response controls support that investigation.

Where Chainguard Actions reduces maintenance

Worked check 2 traced a pinned action to nested code that nobody reviewed. Keeping those references reviewed takes recurring work as upstream projects change.

Chainguard Actions provides hardened replacements for upstream GitHub Actions in a maintained catalog. We review each action’s definition and its repository workflows, and pin every nested action and container reference in that definition to an immutable SHA or digest. Packages the action bundles or downloads at run time remain outside that review. For the actions you adopt, that review takes the place of most of the nested-reference tracing in Practice 4.

Your workflow still needs to adopt updated references. Catalog maintenance doesn't change a SHA already pinned in your repository, and an action replacement doesn't set your cloud trust policy or deployment acceptance rules.

For a pilot, choose an action your release workflow already uses, inspect the corresponding HARDENING.md report, and review the migration changes. Check your allowed-action settings and test the replacement in a representative workflow before expanding adoption. Our CI/CD setup documentation covers migration and ongoing updates.

Start with your highest-risk release path

Before your next release, start with the jobs that can sign, publish, or deploy. Trace whether untrusted code or outputs can reach that authority, then close the unresolved inputs and artifact checks along that path. The access your workflow exposes decides the order.

Record the checks and expected outcomes for the release path you've chosen.

Observed configuration

Required check

Expected decision

Evidence and owner to record

OIDC role accepts an unintended context

Compare actual claims with the effective trust policy, then test allowed and disallowed contexts safely

Intended context accepted, disallowed context denied

Policy revision and both authorization results, owned by the release-role maintainer

Pinned composite action calls a nested action through

@v2

Trace the composite action revision and review a replacement with a pinned nested action

Accept the reviewed composite action with the reviewed nested action reference

Composite and nested action revisions plus remaining mutable fetches, owned by the workflow maintainer

Deployment checks only for an evidence file

Verify the exact image digest, authenticated evidence, and expected build context

Reject missing or mismatched required evidence

Policy, verifier results, and deployed digest, owned by the release-policy maintainer

Assign someone to repair each gap, and keep the recheck results with the workflow change. If reviewing and maintaining action dependencies is the recurring burden, start a Chainguard Actions pilot on that release path. Keep its HARDENING.md report alongside the permission and deployment checks your team owns.

Share this article

Start building with Chainguard

Contact us