Relationships are complicated.
When multiple DevOps platforms work together to execute pipelines for a single GitHub repository, it begs the question: Do these platforms get along?
Node.js, the most popular JavaScript runtime in the world, uses a set of triplets to execute its CI/CD pipelines: a GitHub App, GitHub Actions workflows, and Jenkins pipelines. Like many children, parenting can be a challenge.
Recently, we dove into Node.js’ CI/CD pipelines during vulnerability research. Our investigation revealed gaps that exposed their family of DevOps platforms to remote code execution on internal Jenkins agents and a potential supply chain attack.
Node.js quickly remediated these vulnerabilities. See the end for remediation details.
Tell Me the Impact
Node.js is an open-source, cross-platform JavaScript runtime environment used everywhere, including companies like Netflix, LinkedIn, Uber, and PayPal. The source code for Node.js is stored in the nodejs/node GitHub repository. With over 100,000 stars, nodejs/node is one of the most popular repositories in the world.
An external attacker could abuse gaps in Node’s CI/CD process to:
- Gain code execution and install persistence on Node.js Jenkins test infrastructure
- Potentially compromise Jenkins credentials used during jobs
- Attempt to conduct lateral movement from the internal Node.js network
- Smuggle in unreviewed, unapproved code to the main branch
- This involved a separate CI/CD flow, which was identified by Node.js and will be discussed at the end
Adding code directly to the main branch could result in a direct supply chain attack on Node.js, which could affect all downstream users if the attack goes unnoticed.
Why Were We Looking at Node.js?
In January 2025, I took a break from Red-Teaming and began three months of research for Praetorian. I aimed to explore public GitHub Actions exploitation, building on presentations we’ve given at Black Hat, DEF CON, Schmoocon, and Black Hat Arsenal. Tools and takeaways from this research will be implemented in our CI/CD Professional Services Engagements and into Chariot, our Continuous Threat Exposure Management platform.
One of my goals was to identify vulnerabilities in previously unexplored areas of GitHub Actions. Searching through popular repositories, I noticed many used custom-built GitHub Apps and multiple DevOps platforms to perform CI/CD for a single repository or organization.
My hypothesis was that managing and securing the relationships between several DevOps platforms would be challenging, especially when it is the end-user’s responsibility to manage these relationships.
This hypothesis led me to investigate the nodejs/node repository. Node.js uses:
- A custom GitHub App to control PR labels
- GitHub Actions workflows that trigger Jenkins pipelines
- Jenkins pipelines that run builds and tests
Is it possible to securely use all those platforms on a public repository? Absolutely. But I knew it would be challenging.
After spending three days dissecting Node.js’ CI/CD process, I confirmed my suspicions were correct, but exploitation would not be trivial. It took a combination of race conditions, Git timestamp forgery, and code execution to separate this tightly-knit family.
The Investigation
Node.js uses these CI/CD processes to test, build, and merge code automatically. They carry code from a pull request, all the way into the main branch of the repository, following the correct procedures along the way.
Looking at the Node repository, it wasn’t intuitive which purpose each CI/CD process was responsible for. I began my investigation by diving into each piece, focusing on how and when data was transferred between platforms.
Triplet #1: The NodeJS GitHub Bot
The NodeJS GitHub Bot is a GitHub App that uses GitHub and Jenkins hooks to receive events and interact with the node repository. When an event happens in the Node GitHub repository, or when Jenkins pipelines complete, they send a payload to the App.
After successfully exploiting other custom GitHub Apps, I was hopeful the NodeJS Bot would reveal similar vulnerabilities.
It turns out that much of the NodeJS Bot functionality had been migrated to other systems, such as GitHub Actions workflows. Now, the bot is mostly used to update statuses and post PR comments. It is still interesting from a security perspective, but the small attack surface makes it challenging to find any significant vulnerabilities.
The NodeJS GitHub Bot was like the neglected, smallest sibling. It was noisy but didn’t carry much responsibility.
On to the next.
Triplet #2: NodeJS GitHub Actions Workflows
Node’s GitHub Actions workflows were the packhorse when it came to Node’s GitHub CI/CD. Configured to react to certain events, the responsible, oldest sibling handled PR labeling, running tests, merging code, and triggering Jenkins pipelines.
There were a lot of CI/CD processes in the Node.js environment. Rather than go through every workflow, I decided to focus on one and see if I could identify gaps. I approached their workflows from this perspective: when a user submits a PR, how do Node’s GitHub Actions workflows interact with Node’s Jenkins pipelines?
Looking at previous PRs, you can see three main steps happen when Jenkins pipelines are triggered.
Let’s track down the code behind each of these functions.
1. PR Labeling
When a user submits a PR, one of the first things that happens is the “Label PR” workflow is triggered. This workflow uses the Node PR Labeler, which used to be part of the NodeJS Bot, to apply labels based on which files were changed in the PR. Configuration for this labeler is in the PR label config.
This config showed that changes to certain files, like files in the tools/msvs directory, caused the labeler to apply the needs-ci label. Looking at past PRs, it seemed like PRs with this label would eventually trigger Jenkins pipelines.
2. Maintainer Review and Approval
After the PR is submitted, Node maintainers will review and approve the PR. If the PR needs CI, they will manually add the request-ci label.
3. Jenkins Pipeline Triggering
At some point after the request-ci label is added, Jenkins pipelines are triggered. To investigate this, we dive into Node’s GitHub Actions workflows.
Node has a workflow named “Auto Start CI” that runs every five to ten minutes. This workflow triggers Jenkins pipelines, and I suspected this was where the main interaction occurred between GitHub Actions and Jenkins.
The workflow wasn’t as straightforward as I anticipated. First, it gets a list of all open PRs with the “request-ci” label. It then calls ./tools/actions/start-ci.sh on the PRs in that list.
The start-ci.sh script loops through the PRs and calls “ncu-ci run” on each PR number.
I couldn’t find ncu-ci anywhere in the Node repository. Searching in the NodeJS organization, I discovered it was in the https://github.dev/nodejs/node-core-utils/ repository. This repo contained CLI tools for Node.js Core collaborators, including the ncu-ci tool.
NCU-CI
The ncu-ci tool uses Jenkins credentials to kick off Jenkins pipelines. It performs a lot of steps prior to performing this kickoff, but one important step is calling the checkCommitsAfterReviewOrLabel() function.
This function uses the following GraphQL query to retrieve the timestamp associated with the most recent commit.
You don’t need to understand every piece of that GraphQL query, but you do need to know that ncu-ci uses the “updatedAt” field to keep track of the time associated with the most recent commit on the PR. ncu-ci uses this field to ensure the most recent commit was before a maintainer added the request-ci label.
Why does it do this check?
If a maintainer adds the request-ci label and then a user pushes a new commit immediately after, the Jenkins pipeline will run code from that new commit, which may not have been reviewed and approved. This check prevents a user from smuggling unreviewed, unapproved code into files that Jenkins pipelines will execute.
At least, that was the goal.
So, triplet #2, Node’s GHA workflows, was responsible for:
- Adding labels to a PR
- Waiting for the proper reviews
- Telling triplet #3, the Jenkins pipelines, to conduct the test
Were there any communication gaps?
Finding the Flaw
Now that we understood the GitHub Actions (triplet #2) → Jenkins pipelines (triplet #3) communication, it was time to look for vulnerabilities. Were these two platforms really communicating as well as they should have been?
It seems like, in order to gain code execution within the Jenkins pipelines, we needed to:
- Submit a PR that results in the labeler adding the `needs-ci` label
- Wait for a maintainer to review and approve the code, and add the `request-ci` label
- Pass the commit timestamp check
Submitting a PR that results in the labeler adding the `needs-ci` label is easy — all we need to do is make sure our PR modifies one of the files listed next to `needs-ci` in the config, like any file in the file in the tools/msvs directory.
But, a maintainer won’t approve malicious code, and they definitely won’t add the `needs-ci` label to a PR that adds malicious code to files executed by the Jenkins pipelines. So, the PR has to be legitimate.
That leaves the commit timestamp check. From past experience, I knew that git commit metadata is weird. For example, you can change your username and email to whatever you want. You can set an executable bit on files, update commit messages from the past, and do other random things.
Could you also control git commit timestamps?
Investigating Git Commit Timestamps
Node.js was using Git commit timestamps to perform several security checks, including prior to triggering Jenkins pipelines and performing merges. If I could find a way to bypass this check, it would open up several potential vulnerabilities.
Looking into GitHub’s documentation and various other articles, it seemed like git commit time was taken from the system making the commit rather than being computed by GitHub server-side. This means you could, possibly, control the commit time.
To test, this, I created a new repository, made a new branch, added a commit, and submitted a PR with the commit message “first commit.” Then, I added a “test-label” label.
I ran Node’s GraphQL query used in the commit check and saw that the “updatedAt” field was set to “2025-03-24T23:01:01Z”.
I made another update in my local fork and ran this line to make a new commit:
`GIT_AUTHOR_DATE=”2023-01-15 14:30:00″ GIT_COMMITTER_DATE=”2023-01-15 14:30:00″ git commit -m “Second commit”`.
In the “commits” tab, you can see this commit has a timestamp from 2023.
More importantly, the “updatedAt” field hadn’t changed. Even though I had pushed another commit after the label was added, the “updatedAt” field timestamp was before the label event.
Git commit timestamp forgery meant we could bypass Node’s checkCommitsAfterReviewOrLabel() function, allowing us to add our own code after a maintainer had added the `request-ci` label, but before the workflow kicked off the Jenkins pipelines. In theory, this would allow an attacker to:
- Submit a legitimate PR that modifies a file corresponding to a `needs-ci` label path
- Wait for the Bot to add the `needs-ci` label
- Wait for a maintainer to review and approve the code
- Wait for a maintainer to add the `request-ci` label
- Immediately push a new commit with a forged timestamp predating the label event
- Wait for Node’s GHA workflows to trigger their Jenkins pipelines
- Gain code execution on internal Jenkins agents
Looking through old Node PRs, I observed inconsistent behavior when triggering Jenkins pipelines. Rather than guess at what could or couldn’t prevent this attack, I opted to test my theory.
Execution
To start, I submitted a legitimate PR that upgraded Node’s NASM finder script.
Building The Payload
I needed to add my code to a file executed by the node-test-pull-request Jenkins pipeline. I couldn’t see the pipeline definition, but build logs told me it was checking out code from the fork, running make commands, and executing some Python scripts for testing.
My payload updated the Makefile and a Python test script so that they retrieved and executed a bash script stored in a GitHub gist. This gist would install a self-hosted GitHub runner on their agents and attach it to my own repository. This payload would allow me to execute commands remotely on their agents without risking service disruption.
Timing, Timing, Timing
Node’s DevOps family was dysfunctional, but only for a short amount of time. I needed to take advantage of the race window to smuggle my unreviewed code.
As soon as a maintainer added the request-ci label, I pushed a new commit with an old forged timestamp. This new commit updated the Makefile and a Python test script so that they retrieved and executed a bash script stored in a GitHub gist. I knew the Jenkins pipelines would execute these files by looking at build logs.
The auto-start workflow triggered this Jenkins pipeline, which executed my payload. I watched as several internal Node Jenkins agents connected to my command-and-control GitHub repository.
Some agents, like test-rackspace-ubuntu2204-x64-1, appeared to run multiple jobs without spinning down. This indicated potential lateral movement and privilege escalation opportunities by stealing Jenkins credentials from ongoing builds.
After confirming code execution on over a dozen internal agents, I closed the PR to ensure none of the code was accidentally merged. I performed light filesystem enumeration, but to perform any post-exploitation, I’d need to extend the duration of the Jenkins jobs, which could have negatively affected the agent’s availability. So, I stopped testing and quickly submitted the report to Node.
What About the Supply Chain Attack?
If you remember from the beginning, I claimed that these vulnerabilities could allow an attacker to smuggle in unreviewed, unapproved code to the main branch, leading to a potential supply chain attack.
My initial investigation focused on the request-ci testing process using the Jenkins pipelines. Node.js has another CI/CD process, “commit-queue”, that behaves very similarly to request-ci. When the commit-queue label is added, it triggers GitHub Actions workflows that merge the pull request to main. This process used the same insecure commit timestamp check as request-ci, meaning that an attacker could:
- Submit a legitimate pull request
- Wait for a maintainer to review and approve the code
- Wait until Jenkins CI testing completed
- Wait until a maintainer adds the commit-queue label
- Immediately push a new commit with a forged timestamp predating the label events
- Wait for Node’s GHA workflows to merge the code
Once merged, anyone using future nodejs/node releases would use the malicious code, until the malicious code was identified and removed from the repository and corresponding artifacts.
Node.js identified this issue themselves during their internal investigation after I had submitted the request-ci vulnerability. Hat’s off to Node.js for identifying this even more impactful vulnerability and having the transparency to disclose it.
Remediation Timeline
Node.js reacted with swiftness and seriousness, demonstrating their commitment to security and strengthening their CI/CD security posture.
3/21/25: Submitted the report to Node.js. Later in the day, they temporarily paused the ability to start new Jenkins jobs while they devised a remediation.
3/24/25: Node.js rebuilt Jenkins systems that could have been compromised by an attacker through this vulnerability.
4/1/25: Node merged this PR to NCU-CI, which swapped date validation for approved SHA checks, remediating the request-ci and commit-queue vulnerabilities.
4/1/25: Node.js re-enabled request-ci and reached out to request a retest. I retested the request-ci compromise and confirmed the identified issue was no longer present.
According to their blog post on the vulnerability, “Comprehensive audits were carried out across 140 Jenkins jobs, prioritizing frequently used ones, to detect and remediate vulnerabilities”.
Again, kudos to Node.js for recognizing the severity of these issues, responding appropriately, and strengthening their CI/CD security posture.
Note to security researchers: Node is very supportive of security research. That said, if you believe you have identified vulnerabilities in Node.js’ CI/CD pipelines, they ask that you reach out to give them a heads-up before testing. Refer to Node.js’ SECURITY.md for further guidance.
How Can Praetorian Help
Praetorian has been leading the charge in offensive CI/CD security for several years, inventing novel tooling and giving presentations at Black Hat, DEF CON, Schmoocon, and Black Hat Arsenal.
Our CI/CD Security Assessments can take an in-depth look at your internal CI/CD security posture, enumerating attack paths that an attacker could exploit to go from low-privileged access to complete organization compromise.
Our Continuous Threat Exposure Management (CTEM) platform, Chariot, can continuously identify vulnerabilities in your attack surface before the attackers do.
You can create a free Chariot account anytime. Alternatively, if you’re interested in our managed Chariot offering, or an in-depth CI/CD security assessment, reach out to speak with our team.