The 2025 tj-actions/changed-files compromise (CVE-2025-30066) showed how one compromised GitHub Action can turn an ordinary workflow run into a route for secret exfiltration. The uncomfortable part is that many repositories can find their highest-risk workflow problems in less than an hour, without buying a security platform or redesigning their pipeline.
Run the audit in the order an attacker would use
Do not begin by changing every uses: line to a commit SHA. That is useful, but it is not always the first fix. A workflow triggered by untrusted pull-request code with a write-capable token is more urgent than a read-only documentation workflow using a floating action tag.
Use this 55-minute sequence to inspect one repository. It deliberately starts with trigger and credential combinations, then moves outward to action provenance. The result should be a short, reviewable pull request rather than a security backlog nobody owns.
- Minutes 0–10: list every workflow, trigger, and job permission.
- Minutes 10–20: identify workflows reachable from forks, pull requests, issue comments, or manually supplied inputs.
- Minutes 20–35: inventory third-party actions and reusable workflows.
- Minutes 35–45: pin high-risk action references and remove unnecessary token permissions.
- Minutes 45–55: open follow-up issues for actions that need an owner decision, replacement, or vendor review.
The decision rule is simple: fix any path where untrusted input can execute code while a credential can write, publish, deploy, or access a secret before fixing cosmetic consistency problems. Pinning matters because action tags such as @v4 are mutable references; a full commit SHA identifies one specific revision. But an immutable reference does not make an overly privileged workflow safe.
Inventory workflows, triggers, and action dependencies
Start with the workflow directory. GitHub Actions definitions normally live in .github/workflows, but include reusable workflows called from other workflow files as well. A quick first-pass inventory on macOS or Linux is:
find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) -print
grep -R --line-number --include='*.yml' --include='*.yaml' 'uses:' .github/workflows
grep -R --line-number --include='*.yml' --include='*.yaml' 'pull_request_target' .github/workflows
grep -R --line-number --include='*.yml' --include='*.yaml' 'permissions:' .github/workflows
Make a small worksheet with one row per external reference. Include action references such as actions/checkout@v4, Docker-based actions, and reusable workflows such as acme/platform-workflows/.github/workflows/release.yml@v2. A reusable workflow is a dependency too: its called jobs may request permissions, use additional actions, or handle deployment credentials.
For each row, answer four questions:
- What event starts this workflow:
push,pull_request,pull_request_target,workflow_dispatch, or another event? - Can a contributor outside the repository influence the code, branch name, artifact, issue text, or input consumed by the job?
- Does the job receive
GITHUB_TOKENwrite access, a cloud credential, a package publishing token, or another repository secret? - Is every external
uses:reference pinned to a full commit SHA?
This distinguishes a dependency hygiene issue from an execution-and-credential issue. The latter has a much shorter path from pull request to impact.
Restrict the token before adding more controls
The default question should be “what must this job write?” rather than “what permissions are convenient?” Set repository-wide defaults in GitHub settings where appropriate, then declare workflow or job permissions explicitly so reviewers can see the intended capability next to the code that uses it.
A build that checks out source, runs tests, and uploads a test result generally needs far less than broad write access. Start with an empty permission set, then add only the permissions that cause a documented job capability to work.
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<full-40-character-commit-sha>
- run: npm ci
- run: npm test
Keep publishing and deployment separate from pull-request validation. A release job on a protected branch may need a narrowly scoped write permission or cloud identity permission; a job running code proposed by a fork should not inherit that ability just because both jobs live in the same YAML file.
Pay special attention to pull_request_target. It runs in the context of the target repository, which makes it useful for selected maintenance tasks but dangerous when the workflow checks out or executes pull-request code. Do not treat a changed branch name, a downloaded artifact, an issue comment, or a generated script as harmless merely because the YAML itself was reviewed on the default branch.
Replace a risky all-in-one workflow with separated trust boundaries
This is a common “looks normal in review” pattern. It checks out the pull request’s head commit while using pull_request_target, then grants write access. If a contributor can alter scripts run by npm test, that script runs in a context that is too powerful for untrusted code.
Before: untrusted code and write capability in one job
name: PR checks
on:
pull_request_target:
permissions:
contents: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci
- run: npm test
- uses: tj-actions/changed-files@v46
The problem is not only the floating @v4 and @v46 references. The larger issue is that pull-request-controlled code reaches a job with repository write permission. Pinning both actions would reduce one supply-chain risk while leaving the critical trust-boundary problem in place.
After: test untrusted code with read-only access
name: PR checks
on:
pull_request:
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<full-40-character-commit-sha>
- run: npm ci
- run: npm test
- uses: tj-actions/changed-files@<full-40-character-commit-sha>
If you need to add labels or comments to a pull request, create a separate job with only pull-requests: write, and ensure it does not check out or execute pull-request code. That separation creates a reviewable rule: the job that can modify repository state never runs contributor-controlled scripts.
Prioritize findings by blast radius, not by how easy they are
A repository with 30 workflows can produce 100 or more action references. Pinning all of them may be worthwhile, but it should not delay the four fixes that remove access to secrets, deployment credentials, package publication, or repository writes.
| Finding | Impact if exploited | Typical effort | Priority | First action |
|---|---|---|---|---|
pull_request_target checks out PR code and runs scripts |
High: untrusted code can run in target-repository context | Medium | Fix immediately | Move tests to pull_request; split privileged automation into another job |
Test workflow has contents: write or broad default permissions |
High: token can alter repository state | Low | Fix immediately | Set permissions: {}, then add only required scopes |
| Publishing or deployment secret available to PR jobs | High: secrets or release channels may be exposed | Medium | Fix immediately | Move release work to protected-branch or environment-gated workflow |
Third-party action uses a tag such as @v4 |
Medium to high: tag can change upstream | Low per reference | Fix this sprint | Pin to a reviewed full commit SHA |
| Internal action or reusable workflow uses a branch reference | Medium: internal changes can silently alter callers | Low | Fix this sprint | Pin callers to a commit SHA or controlled release process |
| Read-only scheduled maintenance job has a floating action tag | Lower: limited credentials reduce blast radius | Low | Queue after critical fixes | Pin during the same dependency-maintenance cycle |
The tradeoff people skip is maintenance ownership. Full-SHA pinning prevents a silent upstream tag move, but it also means somebody must deliberately review and update the SHA when a new action release is needed. Treat action updates like dependency updates: use a pull request, inspect the upstream change, run the workflow, and merge an explicit revision change.
Review every third-party action as executable supply-chain code
An action is not just a convenience wrapper. It can run shell commands, access the workspace, read environment variables, receive tokens, download tools, and call network endpoints. The 2025 changed-files incident is a useful reminder that a trusted-looking action can become a pipeline entry point when its upstream distribution is compromised.
For each third-party action, record the owner, pinned SHA, purpose, workflow triggers, and privileges available in the calling job. A small allowlist in SECURITY.md or an internal engineering document is enough for many teams. The important part is assigning a human owner to the dependency rather than treating workflow YAML as unowned configuration.
- Keep: actions with a clear purpose, active maintenance process, and limited job permissions.
- Replace: actions that perform a few shell commands your repository can safely run itself.
- Isolate: necessary actions used for publishing, cloud deployment, signing, or secret-dependent tasks.
- Remove: duplicate actions, abandoned experiments, and actions only used to format data that a short script can handle.
Do not blindly replace every third-party action with inline shell. A mature action may handle edge cases your script misses. The better decision rule is to reduce dependencies where the action adds little value, while demanding stronger pinning and narrower permissions where the action is operationally necessary.
Finish with a pull request your team can merge this week
For the first hardening pull request, keep scope narrow enough to review in one sitting. Change the most exposed workflow first: remove write permissions from PR tests, stop running PR code under pull_request_target, and pin the actions used by that workflow to reviewed full commit SHAs.
Then create a recurring monthly task with three checks. It takes less time than investigating why a previously safe-looking build suddenly began behaving differently.
- Search workflows for new
uses:entries and reject unpinned third-party references in code review. - Search for
pull_request_target,workflow_run, and workflows that use secrets; confirm that untrusted inputs cannot reach privileged commands. - Review explicit
permissions:blocks after workflow changes, especially when adding release, comment, label, package, or cloud deployment steps.
The goal is not a workflow file full of security ceremony. It is a repository where every powerful token has a clear job-level reason, every external action resolves to a reviewed revision, and untrusted pull-request code cannot cross into a privileged execution path.