When tj-actions/changed-files was compromised in March 2025, a seemingly small “which files changed?” dependency sat in more than 23,000 repositories. A workflow can therefore turn one third-party action reference into code execution inside the same runner that builds, signs, or deploys your software.
The useful response is not a 40-item policy document. It is a repository baseline: make external code immutable, ensure untrusted pull requests never reach credentials, verify the exact bytes being deployed, and leave enough evidence to reconstruct a release without exposing its secrets.
Start with four workflow boundaries, not a generic risk list
The OWASP CI/CD risks around ungoverned third-party services, weak artifact integrity validation, and insufficient visibility are connected in GitHub Actions. An action can run arbitrary code; that code can read an over-scoped token or secret; and weak logs or unverifiable artifacts make the resulting release difficult to investigate.
Review every workflow against these four boundaries. This turns a broad supply-chain concern into decisions a repository maintainer can make in a pull request.
| Boundary | Repository rule | Practical check |
|---|---|---|
| Third-party code | Use full commit SHAs for every action. | Search .github/workflows for uses: references ending in tags such as @v4. |
| Credentials | Grant permissions per job and keep deployment credentials out of build jobs. | Look for top-level permissions: write-all, repository secrets in test jobs, and cloud keys in YAML. |
| Release bytes | Hash an artifact before upload and verify it immediately before deployment. | Store a SHA256SUMS or equivalent checksum beside the package. |
| Evidence | Log the commit, artifact digest, deployment target, and outcome. | A failed deployment should identify a run URL, commit SHA, and artifact checksum without printing a token. |
The key decision rule is simple: if a workflow can deploy or obtain a cloud identity, it must not execute code supplied by an untrusted pull request. That includes indirect code such as npm lifecycle scripts, repository shell scripts, and third-party actions.
Pin actions to immutable commits and treat updates as dependency changes
A version tag is convenient, but it is not an immutable security boundary. actions/checkout@v4 identifies a moving reference; a full 40-character Git commit SHA identifies one exact revision. Pin marketplace actions, internal actions, and reusable workflows the same way.
For example, this is a pinned checkout reference:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
Do not copy a SHA from a random issue or another repository. Find the action’s intended release, inspect the commit in the publisher’s repository, and record why the update is being made in the pull request. A pin prevents silent movement; it does not prove that the selected commit is safe.
Use a short allowlist review for each new action:
- What code will run, and does the action invoke a shell, Docker image, or JavaScript bundle?
- Does it need a token, cloud access, or write permission?
- Can a built-in GitHub feature or 10 lines of shell replace it?
- Who owns it, and how will your team receive and review updates?
The maintenance tradeoff is real: pins create update pull requests instead of automatically receiving upstream fixes. That friction is desirable for deployment-capable workflows. A changed action revision should receive the same review attention as a changed production dependency.
Separate untrusted builds from jobs that can access secrets
Use pull_request for normal pull-request validation and avoid placing secrets in those jobs. A fork can submit changes to application code, build scripts, lockfiles, and action inputs. Even a command as ordinary as npm ci may execute package lifecycle scripts, so it belongs in a job with read-only repository access and no deployment identity.
Reserve secret-bearing or identity-bearing work for trusted commits, typically a push to main after review. Prefer OpenID Connect (OIDC) federation to a long-lived cloud access key: the deployment job requests a short-lived identity, while the cloud-side trust policy limits which repository, branch, and environment may assume it.
Scope GitHub’s GITHUB_TOKEN explicitly. A build that only checks out code generally needs contents: read; it does not need permission to create releases, modify pull requests, or write repository contents. Put elevated permissions on the one job that needs them, rather than at workflow scope.
Finally, use GitHub Environments for production. Configure required reviewers and environment-specific secrets or identity rules in repository settings. The YAML should name production; it should not contain an API key that can be copied into a pull request or printed by a debugging command.
Verify artifacts at the deployment boundary, not only during the build
Artifacts are where a successful build becomes a release. A test result proves that one job evaluated code; it does not identify the exact tarball, ZIP file, container image, or mobile package a later job deployed.
At minimum, generate a SHA-256 checksum after packaging, upload the package and checksum together, then run sha256sum --check in the deployment job. The check must happen immediately before the deploy command, after the artifact has been downloaded into that job’s workspace.
A checksum is an integrity control, not a complete provenance system. If an attacker can alter both the artifact and its checksum inside a trusted workflow, the check still passes. For higher-risk releases, add a signature or provenance attestation and verify it against a trusted signer identity outside the build’s writable workspace. The baseline below deliberately starts with checksums because it is easy to adopt and exposes the artifact digest in logs.
Do not deploy “whatever is currently in dist/.” Deploy a named artifact associated with one workflow run, one commit SHA, and one recorded digest. That makes rollback and incident response substantially less ambiguous.
Use this baseline workflow as a starting point
This example tests pull requests without secrets, packages only trusted pushes to main, and verifies the uploaded artifact in a separate deployment job. Replace ./scripts/deploy-with-oidc with your cloud provider’s OIDC-based deployment command.
name: ci-and-deploy
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: npm ci
- run: npm test
- run: npm run build
package:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: npm ci
- run: npm run build
- run: |
tar -czf app-dist.tgz dist
sha256sum app-dist.tgz > app-dist.tgz.sha256
cat app-dist.tgz.sha256
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: app-${{ github.sha }}
path: |
app-dist.tgz
app-dist.tgz.sha256
if-no-files-found: error
deploy:
needs: package
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: app-${{ github.sha }}
- run: |
sha256sum --check app-dist.tgz.sha256
echo "Deploying commit $GITHUB_SHA"
cat app-dist.tgz.sha256
- run: ./scripts/deploy-with-oidc app-dist.tgz
The deployment job has no stored cloud secret in the workflow. Its privileged capability is the OIDC token permission, constrained further by the production environment and by your cloud provider’s trust policy.
Make logs useful without turning them into a secret export channel
Security logs should answer four operational questions: what commit ran, what artifact was produced, where it was deployed, and whether the deployment succeeded. The workflow already prints the commit SHA and checksum; add the deployment URL or target name in the deployment script’s final status message.
Do not dump github, env, or cloud CLI debug contexts to diagnose a failure. GitHub masks configured secret values in many cases, but masking is not a reason to print credentials, encoded configuration, HTTP authorization headers, or deployment command arguments.
For a production failure, preserve these identifiers in the job summary or your deployment system:
- GitHub workflow run URL and run ID
- Repository commit SHA and branch
- Artifact name and SHA-256 digest
- Target environment, deployment timestamp, and result
- The change request or release identifier that authorized production
This is the second-order benefit of the baseline: incident responders do not have to infer whether production received the artifact built from the reviewed commit or a different file produced later in the pipeline.
Apply the baseline to one repository this week
- Search every YAML file under
.github/workflowsand replace tag-based action references with reviewed full commit SHAs. - Add explicit
permissionsat workflow and job level; remove every write permission that no command demonstrably needs. - Confirm that pull-request jobs reference no repository secret and cannot obtain a cloud identity.
- Move production deployment into an environment with required review, then configure OIDC trust for only the intended repository, branch, and environment.
- Package one deployable file, create a SHA-256 checksum, and verify it in a separate deployment job.
- Run a deliberately failing deployment in a non-production environment and check whether the logs reveal the commit, digest, target, and failure reason without revealing credentials.
Once these controls are in place, review new workflow changes as production code. The most important question is not whether a YAML edit looks small; it is whether it changes what code can run with access to your release path.