Reports on the Shai-Hulud campaign described nearly 500 compromised npm packages and more than 100 million downloads, turning an ordinary install step into a potential credential-exfiltration path. The uncomfortable part is that many repositories would struggle to answer a basic question within an hour: which workflows can install untrusted code and then reach a deployment secret?
A GitHub Actions supply-chain review is not primarily a hunt for a bad package name. It is a way to identify the paths where externally maintained code, repository content, credentials, and release artifacts meet. If an upstream action or package is compromised, those intersections determine whether the result is a failed test, a leaked token, or a production deployment.
1. Build a workflow exposure map before reading YAML line by line
Start with an inventory, not a deep review of the first workflow file you open. A repository can have workflows under .github/workflows/, reusable workflow calls, composite actions under .github/actions/, and scripts invoked from shell steps. The workflow YAML is only the entry point.
Run these commands from the repository root and save the results in the audit ticket or pull request. The goal is to produce a finite review list that another engineer can repeat next quarter.
find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) -print
grep -RInE 'uses:|run:|npm install|npm ci|pnpm install|yarn install|pip install|poetry install|bundle install|go install|curl |wget ' .github
find .github/actions -maxdepth 3 -type f -print 2>/dev/null
For every workflow, record its trigger, jobs, external actions, installer commands, secrets, permissions, artifact uploads, artifact downloads, and deployment targets. Treat a reusable workflow reference such as uses: org/repo/.github/workflows/release.yml@ref as third-party executable code when org/repo is outside your control.
| Workflow field | What to record | Why it changes priority |
|---|---|---|
| Trigger | push, pull_request, workflow_dispatch, schedule, release |
Determines who can influence inputs and when the code runs |
| Execution source | Actions, reusable workflows, composite actions, shell scripts, package installers | Identifies externally supplied or dynamically retrieved code |
| Credential reach | GITHUB_TOKEN, repository secrets, cloud credentials, signing keys |
Separates a test-only job from a publishing or deployment path |
| Data handoff | Artifacts, caches, job outputs, release assets | Shows where untrusted output can become deployment input |
2. Inventory both action references and package-install steps
A review that only checks uses: lines misses a large part of the attack surface. npm ci, pip install -r requirements.txt, go install module@version, and a shell command that pipes a remote script into an interpreter all introduce code into a runner. They deserve the same tracking discipline as marketplace actions.
Create one row per executable dependency rather than one row per workflow. For example, actions/checkout, docker/login-action, actions/upload-artifact, an internal composite action, and the npm dependency graph are distinct rows because they have different maintainers, update paths, and privileges.
- Action reference: capture owner, repository, ref, job name, and whether it is a GitHub-maintained, internal, or external action.
- Package installation: capture the package manager, lockfile used, registry, and whether the command permits dependency resolution to change.
- Remote retrieval: capture every
curl,wget, container image pull, or downloaded binary, including its version or digest if present. - Local code: record scripts such as
./scripts/release.sh; these may consume environment variables or artifacts even though they are not third-party actions.
The practical distinction is not “GitHub Action versus dependency.” It is whether code outside the reviewed commit can execute in the job. A lockfile narrows version resolution for many package managers, but it does not make a credential-bearing installation step harmless if a compromised package version is already locked.
3. Flag mutable references and invisible update paths
Next, classify how each external action is pinned. A full commit SHA is the clearest immutable reference in a workflow. A tag such as @v4, @v4.1.0, or @main is easier to read, but it creates a different update decision: the workflow may execute a different commit when the reference changes.
Do not turn pinning into a blanket rule that breaks maintenance. A SHA-pinned action still needs a deliberate update process, and an abandoned SHA can leave you on an outdated release indefinitely. The useful audit output is a list of mutable references ranked by the authority of the job that invokes them.
| Reference pattern | Audit treatment | Typical remediation |
|---|---|---|
owner/action@main |
Highest review priority; branch content can change without a workflow edit | Replace with a reviewed release commit SHA |
owner/action@v4 |
Track as a mutable major-version reference | Pin to a reviewed commit SHA and document the source release |
owner/action@v4.1.0 |
More specific, but still review the repository’s reference model | Use a commit SHA for high-privilege jobs |
owner/action@<40-character SHA> |
Immutable workflow reference for review purposes | Schedule explicit update reviews |
Artifact actions deserve a version check as well as a security review. GitHub announced generally available v4 releases for actions/upload-artifact and actions/download-artifact, with key behavior differences and up to 10x performance improvements. Older artifact action versions were scheduled for deprecation in 2024, so an audit finding here can be both a supply-chain maintenance fix and a workflow reliability fix.
4. Identify secret-bearing jobs before deciding what matters
Not every dependency finding should page the release team. A compromised formatter action in a job with read-only repository contents and no deploy path is not equivalent to a compromised action that receives a cloud role, package registry token, or signing certificate. Mark secret-bearing jobs first, then assess dependencies inside them.
Search for explicit secrets and inspect inherited credentials. The important details are the permissions: block, secrets.* expressions, environment-level secrets, cloud login steps, and any token passed as an environment variable or action input.
grep -RInE 'secrets\.|permissions:|GITHUB_TOKEN|id-token: write|aws-|azure|google|npm publish|twine upload|gh release' .github/workflows
For each job, write down the answer to four questions:
- Can the job read or write repository contents, pull requests, packages, or attestations?
- Does it receive a repository secret, environment secret, cloud credential, registry token, or signing material?
- Can an external contributor influence the commit, pull request, artifact, package version, or command input processed by the job?
- Can the job publish, deploy, create a release, alter infrastructure, or modify another workflow?
Use least privilege as a design constraint, not an after-the-fact permission cleanup. A build job that only needs checkout and tests should not inherit release publishing authority merely because a later job needs it.
5. Trace artifacts as trust boundaries, not just build outputs
Artifacts are easy to overlook because they look like files moving between jobs. In practice, an upload step establishes a producer and a download step establishes a consumer; if the consumer deploys or signs what it downloads, that handoff is a trust boundary.
Map every actions/upload-artifact and actions/download-artifact pair. Record the artifact name, producer job, consumer job, trigger, and whether the consumer has credentials. Do not assume that needs: build alone explains provenance: inspect which files the build job writes and which paths the deployment job actually consumes.
A safe-looking pattern can become risky when a workflow downloads broad output instead of a named artifact. Prefer explicit artifact names and controlled destination paths over “download all artifacts” behavior in privileged jobs. After download, validate what matters for your release process: expected filenames, a manifest, checksums generated by a trusted build step, and the package version intended for publication.
This is also where artifact action upgrades can have operational impact. GitHub’s v4 artifact actions introduced behavioral differences alongside performance improvements. Test artifact naming, overwrite assumptions, and download paths in a non-production workflow before replacing versions in the deployment pipeline.
6. Work one release workflow from trigger to production
Consider a repository with three jobs: test, build, and publish. The test job runs on pull requests and installs dependencies with npm ci. The build job runs on pushes to the default branch, produces dist/, and uploads an artifact named web-package. The publish job downloads that artifact and runs npm publish with an npm token.
The wrong conclusion is “the publish job is protected because only maintainers push to the default branch.” The better review asks how code reaches the publishing token. If build runs a mutable third-party action, installs packages, or executes repository scripts before uploading web-package, then those inputs are on the path to a privileged consumer.
Make decisions at each boundary:
- Pin third-party actions in
buildandpublishto reviewed commit SHAs. - Keep the npm token only in
publish; do not expose it tobuild. - Give
buildminimal permissions needed for checkout and artifact upload. - Download only
web-packageinpublish, not every available artifact. - Validate the downloaded package name and version before publishing.
The second-order benefit is faster incident response. If a package is named in an advisory, you can answer whether it ran in a token-bearing path without reconstructing the pipeline under pressure.
7. Prioritize remediation by blast radius, not finding count
A repository with 40 unpinned actions does not necessarily have 40 equally urgent problems. Score each finding by what it can reach after execution. This prevents a noisy audit from spending two weeks on low-impact documentation workflows while a release job retains a mutable action and cloud credentials.
| Condition | Suggested priority | Example action |
|---|---|---|
| Mutable external code plus deployment, publishing, signing, or cloud credentials | Critical | Pin immediately; reduce permissions; separate credential use from build execution |
| Package install or artifact download feeds a credential-bearing release job | High | Trace producer-to-consumer path; add validation and isolate the privileged consumer |
| Mutable action in a default-branch build with no secrets | Medium | Pin and add an update owner to the maintenance backlog |
| External action in a read-only, manually run reporting workflow | Lower | Pin during normal workflow maintenance |
Increase the score when untrusted contributors can influence inputs, when the workflow runs automatically, or when the same credential can affect several repositories or environments. Lower it only after verifying permissions and downstream handoffs; “this job does not declare a secret” is not enough if it writes an artifact consumed by a deployment job.
8. Turn findings into small, reviewable remediation changes
Large workflow rewrites create their own risk. Split remediation into pull requests that preserve behavior while reducing authority: pin one action family, add explicit permissions to one workflow, move a registry token into a publish-only job, then tighten artifact selection. Each pull request should include a workflow run link or release test proving the pipeline still works.
Use a finding format that captures the decision instead of merely reporting “action is unpinned.” A useful issue includes the workflow file, job, executable dependency, current reference, trigger, credentials available, artifact links, and proposed owner. That makes review possible for both platform engineers and the application team that owns deployment semantics.
Finding:
.github/workflows/release.yml,publishjob, external action on a mutable reference; job receives an npm publishing token and downloadsweb-package. Remediation: pin the action to a reviewed commit SHA, keep the token scoped topublish, and verify the downloaded package version beforenpm publish.
Assign an update cadence for pinned dependencies. Pinning is not “set and forget”; it trades silent updates for explicit review. That tradeoff is desirable in secret-bearing jobs, but it requires someone to own the review queue.
9. Run this 60-minute audit this week
You do not need a full CI/CD platform replacement to get a useful result. Schedule one hour with the engineer who understands releases and the person who owns GitHub organization settings. Start with the workflows that publish packages, create releases, deploy services, or access cloud accounts.
- Export the workflow inventory and locate every
uses:, installer command, remote download, and artifact action. - Mark jobs that use secrets, elevated
GITHUB_TOKENpermissions, cloud identity, registry tokens, or signing material. - Draw arrows from artifact producers to artifact consumers, especially where a consumer deploys or publishes.
- Rank mutable references and package-install paths by credential reach and downstream deployment authority.
- Open remediation tickets for every critical and high path, with the workflow file and job name included.
- Test upgrades to
actions/upload-artifact@v4andactions/download-artifact@v4where older versions remain.
The deliverable is not a spreadsheet full of red flags. It is a short, defensible answer to a future incident question: “Did this compromised dependency execute in a path that could reach our credentials, artifacts, or production?” Build that answer now, while the only deadline is your next planned workflow maintenance window.