The Silent Exfiltration — Why Your CI Pipeline Is an Open Vault
Modern CI/CD pipelines for Node.js applications show three worsening structural issues — secrets injected into the runner environment at the start of the pipeline, unrestricted npm lifecycle script execution during dependency installation, and open outbound network access on CI runners — which together enable silent, zero-alert credential exfiltration by any malicious package in the dependency tree. These findings are platform-independent: GitLab CI, GitHub Actions, and similar systems all have identical default insecure settings. The March 2026 compromise of the Axios npm package, a North Korean state-sponsored supply chain attack targeting a library with about 100 million weekly downloads, is discussed as a real case study confirming the large-scale exploitation of this attack surface.
The Three-Vulnerability Kill Chain
A developer on a feature branch adds a new npm package. The pipeline runs. Within seconds, every credential the organisation owns is on an attacker-controlled server. No merge to the main branch. No code review. No alert. No indication in the logs.
This is not a hypothetical scenario constructed for impact. It is the default behaviour of most Node.js CI pipelines, and it follows from three conditions present simultaneously in the majority of production pipeline configurations — regardless of whether the platform is GitLab CI, GitHub Actions, AWS CodeBuild, CircleCI, or any equivalent system.
Each condition in isolation is manageable. In combination, they constitute a complete exfiltration capability:
- Secrets injected into the environment — all CI/CD variables and repository secrets are available in
process.envfrom the first millisecond of pipeline execution, before any security scanning, before any approval gate, on any branch. - Unrestricted lifecycle script execution —
npm installandyarn installexecute arbitrary code from every package in the dependency tree, including packages three or four levels deep that no engineer on the team has ever reviewed. - Open outbound network access — CI runners have unrestricted egress to the public internet by default, and standard CI images include multiple network tools (
curl,wget,node) suitable for exfiltration.
The result: complete exfiltration capability, zero alerts.
Finding 1 — Secrets Are Live Before Any Gate Fires
Both GitLab CI and GitHub Actions inject secrets into the runner environment at job start — not at the step where they are first referenced, but at the beginning of the job, before any pipeline step executes.
On GitLab CI, project-level and group-level CI/CD variables are injected into the process environment immediately. The platform's "Masked" flag, commonly assumed to protect secret values, is a log sanitisation feature: it string-matches the secret value in stdout and stderr output and replaces it with [MASKED] before the log is stored. The raw value is in process.env and is fully accessible to any code executing within the job.
On GitHub Actions, secrets exposed via env: blocks or workflow-level ${{ secrets.MY_SECRET }} references are equally accessible to any process running in the job environment.
The critical implication is that there is no safe window. A pipeline triggered on any branch, by any developer, against any commit, immediately makes all configured secrets available to all code that runs within it. Security scanning steps, approval gates, and branch protection rules all fire after secrets are already live in the environment.
Finding 2 — npm install Is Arbitrary Code Execution
The npm lifecycle hook system is a first-class feature of the package manager. When npm install or yarn install runs, the following hook sequence fires automatically, in order, for the root project and for every package in the dependency tree:
preinstall → install → postinstall → prepare → prebuild → build → postbuild
These hooks execute shell commands or Node.js scripts defined in each package's package.json. They run with full access to the process environment — including all injected CI secrets — and with full filesystem and network access. There is no sandboxing, no prompting, and no log indication distinguishing legitimate build steps from hook execution.
This behaviour is identical across npm, yarn, and pnpm, and applies on every CI platform that executes npm install: GitHub Actions runners, GitLab Kubernetes executors, AWS CodeBuild projects, Jenkins agents, CircleCI containers, and any equivalent system. AWS CodeBuild runs jobs inside managed containers with no egress restriction or script execution policy applied by default; Jenkins pipelines are similarly unrestricted unless an administrator has explicitly hardened the agent configuration.
The attack vector this creates is precise: a malicious or compromised package anywhere in the transitive dependency tree can execute arbitrary code during installation. That code has full access to every secret in the environment and can exfiltrate them before the install step completes. The malicious code never appears in the repository.
The CI pipeline is not even the first system at risk. The identical hook sequence fires when a developer runs npm install on a local machine. A developer workstation is in many ways a richer target than an ephemeral CI runner: it accumulates ~/.aws/credentials, ~/.ssh/ private keys spanning every service the developer has ever accessed, .env files across multiple local projects, and npm authentication tokens in ~/.npmrc, IDE integration tokens, and VPN configurations. Unlike an ephemeral CI container that is destroyed after the job completes, a compromised developer machine persists, is rarely audited, and frequently has access to production systems that the CI pipeline does not.
Finding 3 — The Network Is Wide Open
CI runners — both GitHub-hosted runners and GitLab Kubernetes executors — have unrestricted outbound internet access by default. Standard CI images include curl, wget, and node, providing multiple independently usable exfiltration channels. A malicious postinstall script requires none of these external tools: Node.js's built-in https module is sufficient to POST an arbitrary payload to any external endpoint.
On GitHub Actions, hosted runners can reach arbitrary external hosts without any configuration. Egress restriction requires migrating to self-hosted runners deployed within a network boundary with explicit egress firewall rules.
In GitLab CI with a Kubernetes executor, egress restrictions require an explicit Kubernetes NetworkPolicy configuration. The default namespace configuration in most deployments does not apply such a policy.
AWS CodeBuild runs jobs inside managed containers in AWS-owned infrastructure; outbound internet access is available by default unless the build project is configured to run inside a VPC with a restrictive security group. Jenkins agents — whether cloud-provisioned or self-hosted — inherit the host's network configuration, which in most enterprise environments means unrestricted outbound access.
A commonly held belief is that corporate SSL inspection proxies provide meaningful egress protection. This assumption warrants scrutiny. An SSL inspection proxy can be bypassed by setting NODE_TLS_REJECT_UNAUTHORIZED=0, which disables certificate validation entirely. More significantly, SSL proxies offer no protection against DNS-based exfiltration: an attacker can encode stolen credentials as subdomain labels in DNS queries to an attacker-controlled domain (dGVzdC1zZWNyZXQ.attacker.com), and these queries will traverse the network regardless of HTTP-level filtering.
This Is Not a JavaScript Problem
The install-time code execution pattern is not specific to the JavaScript ecosystem. Every major package manager exposes an equivalent attack surface:
- Python —
setup.pyexecutes as a standard Python script duringpip installof a source distribution. ThectxandphpassPyPI packages (2022) exploited this mechanism to exfiltrate AWS credentials from CI environments. - Ruby —
gem installexecutes gemspec extension scripts andRakefilehooks. Therest-clientgem was backdoored via this mechanism in 2019. - PHP — Composer's scripts block supports
pre-install-cmdandpost-install-cmdhooks that are structurally nearly identical to npm's lifecycle system. - Rust —
build.rsscripts execute arbitrary Rust code duringcargo buildandcargo installwith full system access. - Java — Maven plugins execute arbitrary code during the build lifecycle. Gradle's
build.gradleis a full Groovy or Kotlin program; a compromised plugin dependency is the equivalent attack vector.
This is not a JavaScript problem. It is a software supply chain problem. The specific hook name changes across ecosystems; the risk does not.
This Is Not Theoretical — It Just Happened
On 31 March 2026, the Axios npm package — the most widely used HTTP client library in the JavaScript ecosystem, with approximately 100 million weekly downloads and an estimated presence in 80% of cloud and code environments — was compromised in a state-sponsored supply chain attack attributed to Sapphire Sleet (also tracked as UNC1069), a North Korean threat actor.
The attack unfolded with significant operational sophistication. Approximately 18 hours before the main attack, a package named plain-crypto-js@4.2.0 was published to the npm registry — a clean, inert decoy designed to establish a brief publication history and reduce the likelihood of detection heuristics triggering. On 31 March at 00:21 UTC, the primary Axios maintainer account (jasonsaayman) was used to publish axios@1.14.1, which introduced plain-crypto-js@4.2.1 as a new runtime dependency. At 01:00 UTC, axios@0.30.4 was published with the same injected dependency. Both the latest and legacy distribution tags were compromised simultaneously, maximising the blast radius across projects using either the current or legacy Axios API.
The delivery mechanism was a postinstall hook in plain-crypto-js@4.2.1 declaring "postinstall": "node setup.js". Upon installation of either compromised Axios version, npm resolved the dependency tree, fetched plain-crypto-js@4.2.1, and automatically executed setup.js with no user interaction. The script deployed platform-specific second-stage payloads — a Remote Access Trojan (RAT) for Windows, macOS, and Linux — and connected to a command-and-control server at sfrclak[.]com:8000.
The malicious versions remained live for approximately three hours before removal. Any CI pipeline — on any platform — that executed a fresh npm install during that window and resolved Axios via a floating version range (^1.14.0) was potentially compromised.
The Axios incident does not stand alone. The same attack pattern has been executed repeatedly: event-stream (2018, targeting a Bitcoin wallet library), ua-parser-js (2021, cryptominer and credential stealer), and colors/faker (2022, deliberate maintainer sabotage). In every case, the vector was identical. In every case, standard code review provided no protection because the malicious code was not in the repository.
The Axios incident establishes definitively that no package is too widely used, too well-maintained, or too carefully watched to be immune.
Test Your Pipeline Right Now
The following three self-contained tests can be run against any pipeline today — with no special tooling and no risk — to confirm whether the conditions described above are present. Each test is reversible and leaves no persistent changes.
Test 1: Confirm Secrets Are Accessible at Runtime
Add a CI job step that runs:
printenv | sort | sed 's/=\(.\).*/=\1***/'
This prints all environment variable names with values redacted to their first character only. Count the secrets present. Observe that they are available before any security step in the pipeline has executed.
Test 2: Confirm Lifecycle Scripts Execute Silently
Add the following to the root package.json temporarily:
{
"scripts": {
"preinstall": "echo '⚠ SECURITY TEST: preinstall executed'",
"postinstall": "echo '⚠ SECURITY TEST: postinstall executed'"
}
}
Run npm install (or yarn install) in the pipeline without --ignore-scripts. Observe that both messages appear in the job log, silently interleaved with normal install output, with no indication that arbitrary code has executed. Remove the test scripts before merging.
Test 3: Confirm Outbound Network Access
Add a CI job step that runs:
node -e "const https = require('https');const req = https.request('https://httpbin.org/post', {method:'POST'}, res => { console.log('Egress status:', res.statusCode);});req.write(JSON.stringify({test: 'egress-check'}));req.end();"
A 200 response confirms that arbitrary POST requests to external hosts succeed from within the CI runner — using only Node.js built-ins, with no additional tools required. If all three tests confirm positive, the kill chain is complete on the current pipeline configuration.
The Remediation Ladder
The following remediations are presented in order of implementation priority. Unless noted otherwise, all recommendations apply equally to GitLab CI, GitHub Actions, AWS CodeBuild, and Jenkins.
Immediate
Adopt --ignore-scripts for all dependency installation steps. This single flag eliminates the entire lifecycle script attack surface and is the highest-impact change available with the lowest implementation cost:
npm install --ignore-scripts
# or yarn install --ignore-scripts
For most pipelines, this change will work without modification. The flag only affects hooks that fire automatically as a side effect of pulling down dependencies — it has no impact on build scripts the pipeline deliberately invokes by name (npm run build, npm run test, etc.), which continue to work exactly as before.
A subset of widely used packages relies on lifecycle scripts to function and will require a small amount of explicit wiring. The most common cases are:
- Prisma — runs
prisma generatevia postinstall by default. Fix: addnpx prisma generateas an explicit pipeline step after install - Husky — installs Git hooks via the prepare script. Fix: add
npx husky installas an explicit step - esbuild — downloads a platform-specific binary via postinstall. Fix: use the
esbuild-wasmvariant or invoke the binary path explicitly - Native addons (bcrypt, sharp, canvas) — prefer pre-built binary variants (e.g.
@img/sharp-linux-x64), or runnpm rebuild <package>explicitly after install
In each case, the fix is the same pattern: move the hook's work into an explicit, named pipeline step. The result is a pipeline that is both more secure and more legible — every action it takes is visible and intentional:
# Step 1: install with no automatic script execution
npm install --ignore-scripts
# Step 2: explicitly invoke only what is needed
npx prisma generate
npx husky install
npm run build
Hygiene Controls (Log Protection, Not Runtime Protection)
Both platforms offer secret redaction features that are worth enabling, but must not be confused with security controls.
On GitLab CI, enabling the "Masked" and "Protected" flags causes the platform to replace matching values in job log output with [MASKED]. Two additional limitations apply: GitLab cannot mask multiline values — an SSH private key spanning from -----BEGIN OPENSSH PRIVATE KEY----- to -----END OPENSSH PRIVATE KEY----- across multiple lines cannot be masked, regardless of configuration, and short or special-character values may silently fail masking requirements.
On GitHub Actions, secrets are automatically redacted from log output. The same fundamental limitation applies: redaction is a log-level operation only.
The correct framing: secret masking and log redaction protect against accidental disclosure to humans reading job logs. They provide zero protection against a malicious process reading the environment programmatically.
Disable debug logging in deployment scripts. Several widely used deployment tools emit full HTTP request headers — including Authorization headers containing bearer tokens — when debug logging is enabled. This is a common misconfiguration: debug logging is enabled once during troubleshooting and never removed, silently printing credentials to job logs on every subsequent run. Review all deployment scripts for --log-level=debug or equivalent flags and remove them.
Rotate credentials that appeared in plaintext log output. Any secret that appeared unredacted in a job log — whether due to debug logging, a printenv call, or a masking failure — should be considered potentially compromised and rotated. Job logs on most platforms are readable by anyone with Reporter-level access or higher, which often includes a broader audience than the team realises.
Short-Term
- Audit the post-install scripts across the existing dependency tree. Run
npm lsoryarn why <package>to enumerate the full dependency tree, then identify all packages that declarepostinstall,install, orpreinstallscripts. - Enforce lockfile diff review in pull requests and merge requests. Changes to
package-lock.jsonoryarn.lockshould be treated as a mandatory, conscious review signal. A new transitive dependency appearing in a lockfile diff is exactly the mechanism through which the Axios attack would have propagated. - Scope secrets to the jobs that require them. Installation steps have no legitimate need for deployment credentials. On GitHub Actions, use job-level
env:blocks. On GitLab CI, use variable protection rules and job-scoped variable assignment.
Medium-Term
Integrate two complementary scanning layers before npm install. The recommended pipeline gate order is: scan lockfile → fail if known-bad → only then run npm install --ignore-scripts.
- “Trivy fs” mode (pre-install): Run
trivy fs --scanners vuln --skip-dirs node_modules .as a pre-install step. It parsespackage-lock.json,yarn.lock, andpnpm-lock.yamldirectly — nonode_modulesrequired — and checks all declared dependency versions against the CVE database. Teams already using Trivy for Docker image scanning can add this step at near-zero incremental cost. - Behavioural analysis (socket.dev or equivalent): Trivy is CVE-driven and blind to novel attacks. The Axios compromise used a brand-new malicious package version with no CVE assigned while it was live. Behavioural tools flag suspicious patterns — a package that suddenly declares a
postinstallscript it never had, or a new transitive dependency injected into a stable package — independent of whether a CVE exists. The two approaches are complementary, not interchangeable. - Restrict outbound network egress on CI runners. Egress restriction is the strongest structural defence in the remediation ladder — the only control that breaks the kill chain at the delivery stage. Even if a malicious package executes successfully, a strict egress allowlist prevents stolen credentials from being transmitted. The attacker has execution but no exit.
Two important caveats apply. First, allowlist maintenance requires ongoing discipline — teams under delivery pressure frequently respond to a broken pipeline by expanding the allowlist rather than investigating the root cause. Second, HTTP-level egress controls are insufficient on their own: an attacker can encode credentials as subdomain labels in DNS queries to an attacker-controlled nameserver, bypassing all HTTP and HTTPS filtering. Comprehensive egress restriction requires a controlled internal DNS resolver in addition to HTTP-level controls.
- Implement a dependency publication cooldown policy. Reject in CI any package version published within the last N days (commonly 3–7). This introduces a window during which the security community can identify and respond to a compromised release before it enters production dependency trees.
Conclusion
The vulnerability class described in this article does not arise from misconfiguration of a specific platform or negligence on the part of engineering teams. It arises from the intersection of three design properties — secrets available at pipeline start, arbitrary code execution during dependency installation, and open outbound network access — that are individually reasonable and present in most Node.js CI configurations by default.
The March 2026 Axios compromise demonstrated that this attack surface is being actively exploited by well-resourced, operationally sophisticated threat actors against packages at the very top of the npm download distribution. The target package had 100 million weekly downloads, multiple active maintainers, and years of established trust. None of these properties provided protection.
The trust model underpinning most CI pipelines is insufficient for the current threat environment. Structural controls that operate independently of trust assumptions are required: --ignore-scripts to eliminate lifecycle script execution as an attack vector, pre-install lockfile scanning to identify known-bad and behaviourally suspicious packages, and egress restriction to prevent exfiltration even in the event of a successful compromise.
Awareness of the attack surface is the prerequisite. The controls described above are the response.
References
- Microsoft Threat Intelligence. (2026). Mitigating the Axios npm supply chain compromise. Microsoft Security Blog. https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/
- Google Threat Intelligence Group. (2026). North Korea-Nexus Threat Actor Compromises Widely Used Axios NPM Package in Supply Chain Attack. Google Cloud Blog. https://cloud.google.com/blog/topics/threat-intelligence/north-korea-threat-actor-targets-axios-npm-package
- Elastic Security Labs. (2026). Inside the Axios supply chain compromise — one RAT to rule them all. https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all
- StepSecurity. (2026). axios Compromised on npm — Malicious Versions Drop Remote Access Trojan. https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan
- Aqua Security. (2024). Trivy — Node.js coverage documentation. https://trivy.dev/docs/latest/coverage/language/nodejs/
- socket.dev. (2024). Supply chain security for npm, PyPI, and Go. https://socket.dev
- Snyk. (2021). The ua-parser-js npm package was compromised. Snyk Blog.
- Aboukhadijeh, F. (2022). The colors and faker npm packages: What happened and what you can do. Socket Blog.
