GitHub Actions learnings from the recent nx hack
You may have seen recent reporting around the compromise of the nx project. A malicious version of their package was published to npmjs which subsequently published GitHub tokens, crypto wallets and other sensitive materials of 1000s of users.
You may have seen recent reporting around the compromise of the nx
project. A malicious version of their package was published to npmjs which subsequently published GitHub tokens, crypto wallets and other sensitive materials of 1000s of users.
The maintainers of the project have spelled out exactly how the project was compromised. Yet, it doesn't tell exactly what they did to prevent the same issue from happening in the future. Or how you can protect yourself from these attack vectors.
I'll walk through the attack chain and will explain what options are available to you to prevent these things from happening.
Understanding the vulnerable workflow
Let's look at the main cause of this hack. The introduction of a new GitHub Actions Workflow that had the pull_request_target
trigger enabled. This trigger is dangerous, since it signals to GitHub that the workflow is safe and has access to secrets. By default only to secrets referenced in the workflow itself, but that always includes the built-in GitHub Actions token.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
The second stage of the hack was possible due to script injection in that same workflow. This allowed the injection of arbitrary code in the pipeline through the title of the pull request:
- name: Create PR message file
run: |
mkdir -p /tmp
cat > /tmp/pr-message.txt << 'EOF'
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
EOF
The pull request's title and body are written to the script prior to execution, so including EOF
in any of them would be interpreted as arbitrary bash commands. And adding$(...)
would also lead to code execution prior to writing the contents to pr-message.txt
.
Since the workflow can now execute arbitrary code, and the GitHub Hosted Runner runs with sudo by default, it would also be possible to write a memory dump of the runner process to gain access to the Runner's GitHub Token.
With this token, it is possible to either write to an unprotected branch on the repo, or to run other workflows. The publish.yml
workflow had the ability to be triggered manually in the master
branch. This means that it can be triggered against any branch in the project repo and will use the contents of the workflow in that specific branch. Because the npm token is stored as a repository secret, it will be available to all workflows in the repository, regardless of the branch it's defined in.
In this case I could have used the GitHub Token to:
- Create a new branch in the project repository
- Overwrite the publish.yml in the new branch to send the npm token to a webserver I control
- Trigger the publish workflow against my branch
- Delete the branch
- Optionally delete the workflow run
Without overwriting the publish.yaml, changes in any of the build scripts executed in the publish job would have had access to the token, since it was added as a job level environment variable.
publish:
if: ${{ github.repository_owner == 'nrwl' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
In the disclosure the project mentions that they have branch rulesets in place to protect their master branch. Without branch protections this would have been even simpler.
Understanding the vulnerable (human) process
This attack couldn't have happened if the workflow above wouldn't have been added, or if it had used the standard pull_request
trigger instead. If you read the documentation for pull_request_target
, it calls out that its dangerous.
It looks like this workflow was generated by AI:
🤖 Generated with Claude Code
And that none of the humans in the loop were aware of the dangers. Nor was there any tooling in place to scan the workflows and to alert them of the potential problems.
Once the attacker figured out the attack chain, it would have been possible to execute the attack in seconds, with only minimal observable traces. And because all actions would be performed by the github-actions[bot]
user, none of the changes would stand out and any commits would even be considered verified if done correctly.
It's trivially easy to setup a repo in which to test this attack without actually having to interact with the nx repo.
Because the NPM token was stored as a repository secret, access to it would not require an explicit approval.
Nor related to this attack, but worrying nonetheless. The actions referenced by the nx project are pinned to their major version tag. This is against best practices for GitHub Actions in public repos. For example:
- uses: actions/checkout@v4
Best practices require each action to be pinned to their commit sha. In this case as far as I can tell none of the referenced actions have a known vulnerability, but this leaves the project open to attacks similar to the tj-actions/changed-files hack from a few months back:

Remediation
Let's break down the remediations that can be put in place (and which ones are default for new organizations):
- Change the default permissions for GitHub Actions to read-only.
- Set workflow permissions at the job level
- Require Approval for running fork pull request workflows from contributors
- Do not rely on
pull_request_target
. - Do not use
workflow_dispatch
orrepository _dispatch
triggers in workflows that have access to critical secrets. - Store critical secrets in environment secrets, instead of repository secrets.
- Pass secrets only to the steps that need them
- Pass data to script steps using the environment
- Pin all actions to their sha.
- Enable GitHub Advanced Security Code Scanning for GitHub Actions and add Branch Ruleset.
- Enable Dependabot for GitHub Actions to automatically update action references to the latest version.
- Consider 3rd party security hardening tools like StepSecurity's Harden Runner.
Each of these remediations is discussed in more detail below.
Change the default permissions for GitHub Actions to read-only
When GitHub Actions was first released, the default permissions were set to write-all. When GitHub changed the default permission to read-all later, they did not retroactively reset the permission for existing accounts and organizations.
GitHub Enterprise Owners and GitHub Organization Owners can enforce a default permission of read-all through the Settings:

This will break all workflows that do not explicitly declare their required permissions in case they need more than read-all.
While you're at it, you should also turn off Allow GitHub Actions to create and approve pull requests. Allowing this setting offers attackers a way to bypass branch rulesets under certain conditions.
Set workflow permissions at the job level
A workflow can declare its required permissions at the workflow or at the job level. For best security, it's recommended to deny all permissions at the workflow level and explicitly declare permissions for each job specifically:
# Opt out of any default permissions
permissions: {}
jobs:
build:
# explicitly request permissions
permissions:
contents: read
deploy:
permissions:
contents: read
packages: write
This also removes write permissions in case the default permissions are set to read and write.
Require approval for running fork pull request workflows from contributors
This is a controversial one. As it adds quite a bit of overhead to project teams receiving many 3rd party contributions. But it might have been enough for a maintainer to detect the script injection attack that was used to exfiltrate the GitHub Actions token.
GitHub Enterprise Owner, Organization Owners and Repository Admins can set a policy to require approval before running any GitHub Actions workflow.

While it might feel tempting to set this policy to any of the lighter settings, there are already known cases where a person appears helpful by fixing a few typos. They'll be considered trusted after that.
Setting this policy will require a maintainer to review the Pull Request and trigger the workflows each time an external contributor pushes new commits.
Do not rely on pull_request_target
The pull_request_target
is inherently dangerous. It opens you up to all kinds of additional attack paths and very few people are completely aware of the implications. It is very easy to make a mistake when you enable this trigger on a workflow and open your repository up for attacks.
My recommendation is to not rely on the pull_request_target
trigger. For most scenarios the pull_request
trigger is sufficient.
In the enterprises I control we've added a Custom Pattern for Secret Scanning to block all commits that contain pull_request_target
:

And we enabled this pattern for Push protection:

While there are cases where pull_request_target
can be useful, it's generally not worth the security risks.
⚠️ Warning
For workflows that are triggered by thepull_request_target
event, theGITHUB_TOKEN
is granted read/write repository permission unless thepermissions
key is specified and the workflow can access secrets, even when it is triggered from a fork. Although the workflow runs in the context of the base of the pull request, you should make sure that you do not check out, build, or run untrusted code from the pull request with this event. Additionally, any caches share the same scope as the base branch. To help prevent cache poisoning, you should not save the cache if there is a possibility that the cache contents were altered. For more information, see Keeping your GitHub Actions and workflows secure: Preventing pwn requests on the GitHub Security Lab website.
Do not use workflow_dispatch
or repository_dispatch
triggers in workflows that have access to critical secrets.
Since workflow_dispatch
and repository_dispatch
allow an attacker to control the branch against the workflow will run, it can be used to exfiltrate repository secrets if the attacker has write access to any other branch on the repository.
The only way to protect secrets from this attack path, is to store them as an environment secret.
Store critical secrets in environment secrets, instead of repository secrets.
Secrets defined at the repository level are accessible to all workflows. There is no way to limit access to repository level secrets.
In order to protect a critical secret, such as the NPM token used to publish your package, it should be defined as an Environment Secret.
In the Environment's policy you can then set a branch policy to limit which branches have access to the secret.

Optionally add Required Reviewers to prevent access to the secrets without manual approval:

Then update the workflow to reference the environment when defining the job:
jobs:
deploy:
environment: public
steps:
uses: actions/deploy@sha
with:
npm_token: ${{ secrets.NPM_TOKEN }}
Pass secrets only to the steps that need them
In the nx repository the NPM_AUTH_TOKEN
is defined at the job level:
publish:
env:
# DO NOT USE THIS!
GH_TOKEN: ${{ github.token }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- ...
This makes the secrets available to all steps in the workflow. If the attacker can take control of any of the code executed in these steps, they would be able to exfiltrate the secret.
In this case the job also calls pnpm build:wasm
which adds a number of additional places to hide exfiltration code.
It's better to explicitly pass the secrets only to the steps that need them:
publish:
env:
# NO SECRETS SET HERE
steps:
- ...
- ...
- name: Publish
env:
VERSION: ${{ needs.resolve-required-data.outputs.version }}
DRY_RUN: ${{ needs.resolve-required-data.outputs.dry_run_flag }}
PUBLISH_BRANCH: ${{ needs.resolve-required-data.outputs.publish_branch }}
NX_VERBOSE_LOGGING: true
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo ""
# Create and check out the publish branch
git checkout -b $PUBLISH_BRANCH
echo ""
echo "Version set to: $VERSION"
echo "DRY_RUN set to: $DRY_RUN"
echo ""
pnpm nx-release --local=false $VERSION $DRY_RUN
Pass data to script steps using the environment
The Script Injection attack was possible because variables were in-lined in the body of a script step:
- name: Create PR message file
run: |
mkdir -p /tmp
cat > /tmp/pr-message.txt << 'EOF'
${{ github.event.pull_request.title }} <- unsafe variable reference
${{ github.event.pull_request.body }} <- unsafe variable reference
EOF
To pass data safely to scripts, you should instead pass them through the environment:
- name: Create PR message file
run: |
mkdir -p /tmp
echo $PR_TITLE >> /tmp/pr-message.txt
echo "" >> /tmp/pr-message.txt
echo $PR_BODY >> /tmp/pr-message.txt
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
Script Injection can be detected by GitHub Advanced Security Code Scanning for Actions and ActionLint.
Pin all Pactions to their sha.
The attack on nx doesn't seem to have relied on this attack vector, but the recent attack on tj-actions/changed-files has shown again that it's dangerous to rely on only the tag to reference actions of 3rd parties.
GitHub recommends that you always reference actions you do not control using their explicit commit sha.
For example:
- uses: actions/checkout@v4
Should have used:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
There is now a policy which requires all actions to be pinned to their commit sha:

Because this is a new policy, GitHub Administrators must explicitly opt-in to this policy.
There are several tools available to perform the pinning on your behalf:
Enable GitHub Advanced Security Code Scanning for GitHub Actions and add Branch Ruleset.
The script injection vulnerability at the root of this attack would have been detected by GitHub Advanced Security Code Scanning for GitHub Actions.
GitHub Advanced Security is free for all public repos, so there is not really a good reason not to enable it.
Because support for GitHub Actions was added recently, GitHub Repository Owners must explicitly enable GitHub Actions in the Code Scanning configuration:

Or add actions
to the list of supported languages in their workflow:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
# Make sure Actions is added to the list of langages:
- language: actions
build-mode: none
This will detect a number of GitHub Actions vulnerabilities, including:
- Script Injection
- Unsafe checkout
In order to prevent workflows with these vulnerabilities to enter the repository, add a branch ruleset that requires the Code QL workflow to succeed:

And require Code Scanning results:

An alternative for GitHub Advanced Security Code Scanning for Actions that would also have detected the Script Injection vulnerability is ActionLint. Actionlint can also be added as a workflow.
Enable Dependabot for GitHub Actions to automatically update action references to the latest version.
To automatically keep your referenced GitHub Actions up to date, especially when you are pinning to the full commit sha, you can enable Dependabot to automatically create a pull request whenever an action is updated.
To enable Dependabot for GitHub Actions, explicitly add github-actions
to your .github/dependabot.yml
:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
To reduce maintainer burden, GitHub Actions can be used to automate updates completely.
Consider 3rd party security hardening tools like StepSecurity's Harden Runner.
All of the above remediations are built-in features of the GitHub platform.
There are 3rd party solutions available to further secure GitHub Actions. Of those StepSecurity's Harden Runner is probably the best-known option. You can add harden-runner to any workflow by adding a single step to your workflow jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: audit
The Community version of Harden runner offers the following protections to all of workflows:
- CI/CD-Aware Event Correlation: Each outbound network connection, file operation, and process execution is mapped to the exact step, job, and workflow where it occurs.
- Automated Baseline Creation: Harden-Runner builds a baseline for each job based on past outbound network connections.
- Anomaly Detection: Once the baseline is created, any future outbound calls not in the baseline trigger a detection.
- Block Network Egress Traffic with Domain Allowlist: Optionally use the automatically created baseline to control outbound network traffic by specifying allowed domains, preventing unauthorized data exfiltration.
- Detect Modification of Source Code: Monitor and alert on unauthorized changes to your source code during the CI/CD pipeline.
Conclusion
GitHub offers many features to protect your project against attacks that try to abuse GitHub Actions. Unfortunately, many of these features require explicit opt-in or configuration.
This should not be an excuse to leave your open-source projects open to attack.