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.

GitHub Actions learnings from the recent nx hack
Photo by Clint Patterson / Unsplash

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.

Malicious versions of Nx and some supporting plugins were published
## Summary Malicious versions of the [`nx` package](https://www.npmjs.com/package/nx), as well as some supporting plugin packages, were published to npm, containing code that scans the file syst…

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:

CVE-2025-30066 - How Malicious Commits in tj-actions/changed-files Leaked GitHub Secrets
In March 2025, the popular GitHub Action named tj-actions/changed-files faced a major supply chain security incident. This vulnerability, tracked as CVE-2025-30066, allowed malicious actors to expose GitHub secrets by secretly modifying the action’s code and stealing secrets from build logs. In this post, we’ll break down what

Remediation

Let's break down the remediations that can be put in place (and which ones are default for new organizations):

  1. Change the default permissions for GitHub Actions to read-only.
  2. Set workflow permissions at the job level
  3. Require Approval for running fork pull request workflows from contributors
  4. Do not rely on pull_request_target.
  5. Do not use workflow_dispatch or repository _dispatch triggers in workflows that have access to critical secrets.
  6. Store critical secrets in environment secrets, instead of repository secrets.
  7. Pass secrets only to the steps that need them
  8. Pass data to script steps using the environment
  9. Pin all actions to their sha.
  10. Enable GitHub Advanced Security Code Scanning for GitHub Actions and add Branch Ruleset.
  11. Enable Dependabot for GitHub Actions to automatically update action references to the latest version.
  12. 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:

Set the default workflow permissions to: Read repository contents and package permissions.

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.

Require approval for all external contributors

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:

Define a custom pattern to block pushes

And we enabled this pattern for Push protection:

Enable custom pattern for push protection

While there are cases where pull_request_target can be useful, it's generally not worth the security risks.

From the docs:

⚠️ Warning
For workflows that are triggered by the pull_request_target event, the GITHUB_TOKEN is granted read/write repository permission unless the permissions 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.

Set specific branches to limit access to critical secrets.

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

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:

Require actions to be pinned to a full-length 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:

Enable GitHub Actions for CodeQL Code Scanning

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:

Enable Require status checks to pass and add the CodeQL check explicitly

And require Code Scanning results:

Enable Require code scanning results and add CodeQL explicitly.

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.