Isolating Build Jobs
Overview⌗
We live in an age of rampant supply chain attacks. This class of attacks has been around for a long while, but TeamPCP has significantly increased their visibility among security professionals, developers, and executives alike. TeamPCP’s Shai-Hulud needs no introduction, but it is still worth briefly covering their TTPs and the TTPs of campaigns like it.
TeamPCP, and many other groups, have adopted a rapid smash-and-grab style of attacks. They sacrifice covert, long-term infiltration for rapid exploitation and blast radius amplification. The first step is to compromise either a developer with significant access to an open source project or a misconfigured pipeline, such as via poisoned pipeline execution. This initial foothold provides the ability to publish malicious packages, overwriting legitimate ones. While the exact malware bundled into these packages varies, the end result is the same: on installation, all well known credential locations are pillaged. These credentials are then used to publish a new set of malicious packages. This process repeats, rapidly spreading across the open source ecosystems and internal systems.
There are two key take aways with this methodology. First, the malicious packages are setup to detonate immediately on installation. This occurs during the build phase, the continuous integration piece of CI/CD workflows. Second, the malware actively publishes malicious packages. This is the publish phase, the continuous deployment piece.
Despite being two distinct phases, these attackers have found success pivoting from one phase to the other. This is largely because developers have stopped delineating between them. CI/CD is regularly treated as a singular thing.
Isolate the Build Phase; Protect the Publish Phase⌗
Described through this lens, the solution becomes obvious: don’t give your build phase jobs the ability to publish packages. Lets look at a simple GitHub Action workflow. We’re going to assume no lock file is used so the package installation step could feasibly pull in a compromised package.
name: Publish Package to npmjs
on:
release:
types: [published]
# expose the NPM token for publishing to NPM
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
# checkout source code
- uses: actions/checkout@v6
# install nodejs
- uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
# pull & detonate malicious package
- run: npm ci
# push new package version
- run: npm publish
While this example is contrived, it is also a pattern I’ve seen used in multiple environments. An Action defined this way exposes the NPM authentication token to every step of every job, despite it only being needed for the final npm publish command. This fundamentally is a violation of the principle of least privilege, exposing an access token to things that do not need it. For this particular example, the fix is ostensibly straightforward: only map the NPM_TOKEN secret to an env var when publishing.
name: Publish Package to npmjs
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
# checkout source code
- uses: actions/checkout@v6
# install nodejs
- uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
# no NPM token available for theft during execution
- run: npm ci
# NPM token only exposed during the publish step
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
While an improvement, there are still issues with this. The first problem is due to NPM’s lifecycle scripts. While many malicious packages embed their execution within the postinstall or similar hooks that trigger on installation, NPM also exposes hooks that trigger on publish, such as postpublish. Putting the NPM-specific problem aside, there is a second issue: an OIDC token is issued to all job steps. Whether or not this is an issue depends on if there are any third-party resources configured to allow authentication via this pipeline’s identity.
OIDC⌗
Historically, these campaigns have largely targeted and leveraged long lived access tokens. Leveraging OIDC instead does provide a slight mitigation, at least so far. Attackers cannot simply exfiltrate tokens and immediately begin using them. Instead, they must first exchange the OIDC token for a short-lived access token against the given service. Discussing the details of OIDC is far outside the scope of this post, but you can refer to this post from Curity.io for details. GitHub also has some documentation on using OIDC within a CI/CD context.
Technically speaking, there is nothing stopping a supply chain attack from leveraging stolen OIDC tokens. And in fact, the recent Mini Shai-Hulud variant did exactly this. However, this specific attack was limited to targeting NPM. From what I’ve seen publicly discussed, other services where not targeted. To see how this works with OIDC, lets take a look at the Action from NPM’s own documentation:
name: Publish Package
permissions:
# Issue an OIDC token
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- run: npm ci
- run: npm run build --if-present
- run: npm test
- run: npm publish
As in the example above, an OIDC token is exposed to all jobs. Fixing this is conceptually identical to isolating secrets. However, due to how GitHub Actions work, the tokens cannot be controlled at the step level which means we now need two jobs. Fixing the example above:
name: Publish Package to npmjs
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
# No OIDC token is available during this job
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- run: npm ci
- run: npm run build --if-present
- run: npm test
# Upload the package as an artifact, allowing it to be
# referenced in the publish job
- uses: actions/upload-artifact@v4
with:
name: npm-package
path: |
.
!node_modules
!.git
retention-days: 1
publish:
needs: build
runs-on: ubuntu-latest
# Expose an OIDC token
permissions:
contents: read
id-token: write
steps:
- uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
# Download the artifact
- uses: actions/download-artifact@v4
with:
name: npm-package
# Publish using the OIDC token
- run: npm publish
OIDC Quirks⌗
There is a practical limitation to exploiting OIDC configurations en-masse, namely that environment-specific information must be included when exchanging an OIDC token for credentials. The amount of information required varies depending on the service you are authenticating against. For example, NPM only requires you to provide an audience, which is static across all environments using this configuration. In other words, an attacker doesn’t need to understand anything about the environment they’re targetting. More specifically: all the environment specific information an attacker needs is embedded directly within the OIDC token itself.
In contrast, authenticating against AWS requires more information. Specifically, you must provide an IAM role ARN to assume. Here is a (slightly modified) example from GitHub’s documentation:
name: AWS example workflow
permissions:
id-token: write
contents: read
jobs:
S3PackageUpload:
runs-on: ubuntu-latest
steps:
- name: Git clone the repository
uses: actions/checkout@v6
- name: configure aws credentials
uses: aws-actions/configure-aws-credentials@v6
with:
# this value is unique in every environment
role-to-assume: arn:aws:iam::000012345789:role/s3-publish
role-session-name: githubworkflow
aws-region: us-east-1
- name: Copy index.html to s3
run: |
aws s3 cp ./index.html s3://my_bucket/
In this context, a piece of malware could not immediately pivot to exchanging the OIDC token for AWS credentials without first discovering the role ARN. This should not be treated as a security control by any means, but it is a notable quirk worth calling out. Unfortunately, this quirk nets you nothing for public package providers. PyPI, Crates.io, and other similar platforms operate like NPM does. Further, this information could also be exposed depending on how a pipeline is built. If a repository is checked out during the exploited job, this information could be enumerated from the on-disk pipeline file. Alternatively, it is relatively common to expose this information as environment variables.
As an aside, it is interesting to note that GitHub’s example exposes the OIDC token to all jobs by configuring it at the root of the Action manifest. This is fine in the context of this specific example, but the documentation itself does not include any warnings about this. The AWS blog post on this topic does the same. As mentioned above, the same is true for NPM’s own documentation, and their examples are fully exploitable out-of-the-box.
Gold Standard Protections⌗
While this is certainly useful, it is not the most significant means of mitigating supply chain attacks. Discussing these in detail is outside the scope of this post, but as a brief recap there are two primary controls every organization should adopt.
First, all package dependencies should avoid version tags. Generally speaking, tags are mutable, and this is exactly what this class of attacks take advantage of. That safe v2.3.1 package you installed last week may be overwritten with malware next week. Instead, dependencies should rely on commit hash pinning. Commit hashes are immutable; they cannot be overwritten. All modern package management systems allow for lock files to be generated against commit hashes. This should also be used for reusable workflows. For example, the AWS role assumption workflow referenced above could be rewritten as: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502.
Second, package managers should be configured with a dependency cooldown. This control again emerges from observing the smash-and-grab nature of these campaigns. Malicious packages are published with little regard for stealth, and because of this, are quickly detected and taken down. These packages may be alive for a few hours, or even a few minutes. Dependency cooldown is a configuration that tells a package manager to ignore packages that have been published within a given window.
There are nuances to this, such as GitHub’s immutable releases which allows you to safely rely on known-safe version tags. Or configuring internal artifact registries that upstream public registries. There are also framework-specific protections you can enable, such as disabling lifecycle scripts during npm execution using the --ignore-scripts flag.
Summary⌗
It is important to again stress that this mitigation is tailored to modern adversarial campaigns. This is a mitigation against direct credential exfiltration from payloads that trigger during the build stage. A sophisticated attacker could cause malicious code to trigger during deployment, or even post-deployment when the package is live within the environment. This is particularly notable when dealing with dependency confusion attacks, a far more targeted class of supply chain attacks. But again: security is best implemented in layers.
If you have not already configured your organization’s environment with commit hash pinning and dependency cooldown, go do that now. Right now. But security is best implemented in layers. If you’ve already got this wrangled, it’s worth looking into isolating the build phases of your pipelines. This won’t do anything to prevent developers themselves from pulling a malicious packages, but pipelines are what have significant environment access. A compromised publish pipeline functionally has administrator rights within production environments. And while these campaigns have largely acted as worms, there is nothing stopping them from advancing their capabilities and broadening their post-compromise target list.