- Read the manifesttruststhe package names you typed. A typo or an AI-suggested name resolves to whoever registered it.
- Resolve versionstrustsregistry metadata and, for range-based resolvers, whatever version is newest in range right now.
- Fetch archivestruststhe registry or mirror that serves the tarball, wheel, crate, or jar.
- Verify integrityprovesthe bytes match what the registry published. It says nothing about whether those bytes are safe.
- Write files to disktruststhe archive layout, including files no manifest field mentions.
- Run dependency codeexecutesinstall scripts, build scripts, or native builds, if this package manager allows it by default.
- Write the lockfilerecordsexact versions and hashes so the next install repeats this one.
Package Manager
A package manager resolves, downloads, verifies, and installs software dependencies from a registry, and decides when their code first runs.
Also known as package managers, dependency manager, language package manager, system package manager
A package manager (also called a dependency manager) is a tool that turns declared dependencies into installed code. It resolves version ranges against a registry, downloads and verifies archives, writes them to disk, and records the result in a lockfile. Package managers like npm, pip, and Cargo also decide when third-party code first runs on your machine.
Two properties of a package manager decide how much damage a malicious release can do once it reaches a registry. The first is resolution policy: whether a fresh install picks up a version published five minutes ago. The second is the first execution point: the earliest step at which a dependency's code runs. For most of npm's history, both answers were the worst case. Version ranges resolved to the newest match, and every dependency's install scripts ran automatically. That changed on July 8, 2026, when npm v12 shipped with install scripts off by default.
We think default-on install scripts were a design mistake, and the major JavaScript package managers have now corrected it. But blocking one execution point does not remove the code from your machine. It moves the attacker to the next point the package manager leaves open: a native build, a build script, an interpreter startup hook, or your application's first import. Every ecosystem has one. The useful question for any package manager is where that point sits by default and who gets to approve crossing it.
What is a package manager?
#A package manager is the program that reads a project's list of dependencies and makes the right version of each one, plus each of their dependencies, appear on disk. You declare direct dependencies; the package manager discovers the transitive dependencies that those packages declare in turn, which usually far outnumber the ones you chose. Almost every language package manager is built from the same parts:
- A manifest that lists direct dependencies and acceptable versions, such as
package.json,pyproject.toml,Cargo.toml, orgo.mod. - A registry client that queries a server indexing every published version of every package, such as the npm registry or PyPI.
- A resolver that picks one concrete version per package so that every declared range is satisfied, usually written as semantic versioning ranges like
^1.13.5. - A fetcher and verifier that downloads archives and checks each one against a hash published in registry metadata.
- An installer that lays files out on disk, in
node_modules,site-packages, or a shared cache. - A lockfile writer that records the exact versions and hashes chosen in a lockfile, so the next install can repeat this one.
- A script runner that, in some ecosystems, executes code shipped by dependencies during install or build.
A package manager is not the registry it talks to. npm is both a command-line client and a registry, and pnpm, Yarn, and Bun are separate clients that install from the same npm registry. Several clients sharing one registry is common: pip, uv, and Poetry all install from PyPI.
System package managers such as apt, dnf, and Homebrew install software for an operating system rather than libraries for one project. The difference that matters for security is who can publish. A Debian package reaches apt only after a distribution maintainer uploads it to a signed archive. Anyone with an account can publish to the npm registry or PyPI within seconds. Both models run package-supplied code during installation: Debian packages ship maintainer scripts named preinst and postinst, and npm packages ship lifecycle scripts. Distributions put a curation step in front of that hook. Open registries do not, which is why the install hook became such a reliable attack path for malicious packages.
The major language ecosystems, their registries, and the files each package manager reads and writes:
| Language | Package managers | Default registry | Manifest | Lockfile |
|---|---|---|---|---|
| JavaScript, TypeScript | npm, pnpm, Yarn, Bun | npm registry | package.json | package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock |
| Python | pip, uv, Poetry | PyPI | pyproject.toml, requirements.txt | uv.lock, poetry.lock, pylock.toml |
| Rust | Cargo | crates.io | Cargo.toml | Cargo.lock |
| Go | go command | Go module proxy and checksum database | go.mod | go.sum (hashes; go.mod already pins versions) |
| Java, Kotlin | Maven, Gradle | Maven Central | pom.xml, build.gradle | Gradle lockfiles (opt-in); none in Maven |
| Ruby | gem, Bundler | RubyGems.org | Gemfile | Gemfile.lock |
| PHP | Composer | Packagist | composer.json | composer.lock |
| .NET | NuGet | nuget.org | .csproj | packages.lock.json (opt-in) |
Each registry has its own entry in this glossary: crates.io for Rust, Go modules for Go, Maven Central for the JVM, RubyGems for Ruby, Packagist for PHP, and NuGet for .NET.
How does a package manager install a dependency?
#A package manager installs a dependency by running the same seven-step pipeline in every ecosystem, and each step trusts something different. The figure walks through one install and marks the two steps where most supply chain attacks take effect.
The same pipeline as a table, with the attack that targets each step:
| Step | What the package manager trusts | Attack that targets it |
|---|---|---|
| 1. Read the manifest | The package names you typed | Typosquatting and hallucinated package names |
| 2. Resolve versions | Registry metadata; for range resolvers, the newest version in range | A malicious version published into a popular range; dependency confusion between private and public names |
| 3. Fetch archives | The registry or mirror serving the file | Rare in practice; registries serve over TLS from a CDN |
| 4. Verify integrity | That bytes match the registry's hash | None needed: a compromised maintainer's upload has a valid hash |
| 5. Write files to disk | The archive's full contents | Files that no manifest field declares, such as startup hooks |
| 6. Run dependency code | Every package allowed to run scripts | Install scripts, native build hooks, build scripts |
| 7. Write the lockfile | The result of steps 1 to 6 | Nothing; it faithfully records a malicious version too |
Steps 1 and 2 are where names and versions go wrong. A misspelled or AI-suggested name installs whoever registered it, which is typosquatting. An internal package name that also exists on a public registry can resolve to the public copy, which is dependency confusion. And a range like ^1.13.5 accepts any later 1.x release, including one an attacker published after a maintainer account takeover.
You can see what a package manager learns during resolution by asking the registry directly. npm clients request abbreviated metadata with a special Accept header. This is the real response for esbuild 0.28.2, trimmed to the security-relevant fields, as of October 23, 2026:
$ curl -s -H 'Accept: application/vnd.npm.install-v1+json' \
https://registry.npmjs.org/esbuild \
| jq '.versions["0.28.2"] | {name, version, hasInstallScript,
dist: (.dist | {integrity, signatures, attestations})}'
# second signature and full sig values trimmed
{
"name": "esbuild",
"version": "0.28.2",
"hasInstallScript": true,
"dist": {
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"signatures": [
{
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
"sig": "MEYCIQCUNxdzUXI/rpHaMMcf..."
}
],
"attestations": {
"url": "https://registry.npmjs.org/-/npm/v1/attestations/esbuild@0.28.2",
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
}
}
}Four fields carry the trust model. integrity is a SHA-512 hash of the tarball, which the client checks after download. signatures is the registry's ECDSA signature over the string name@version:integrity, as npm documents for registry signatures. attestations links a provenance statement saying which CI workflow built the tarball, covered in our entry on package provenance. hasInstallScript is a single boolean.
An integrity hash proves you received the bytes the registry published. It does not prove those bytes are safe. A compromised maintainer who publishes through the normal pipeline produces a valid hash, a valid registry signature, and, when the pipeline itself is used, valid provenance. Note also what hasInstallScript leaves out. The client learns that a package wants to run code before it downloads anything, but not what that code does. Answering that question requires reading the package contents.
Lockfiles make repeat installs deterministic, not new installs safe
#A lockfile freezes the output of step 2 so a later install skips resolution and fetches the same bytes. That protection is real, and it is narrow. The axios compromise in March 2026 showed where it ends: a malicious axios 1.14.1 fell inside the ^1.x ranges that most dependents declare, and anything that resolved fresh during the exposure window could pick it up. As our analysis of its blast radius put it, "Lockfiles make existing installs deterministic. They do not make new installs safe."
Fresh resolution happens more often than teams assume. It happens when you add or update a dependency, when a bot opens an update pull request, when CI runs without a committed lockfile, and every time npx or a global install fetches a tool that is not in your project's lockfile. It is also time-dependent. The same command run before, during, and after an exposure window produces three different trees, and the registry no longer serves the bad version afterward:
$ npm view axios@1.14.1 version
npm error code E404
npm error 404 No match found for version 1.14.1
npm error 404
npm error 404 'axios@1.14.1' is not in this registry.Resolvers do not have to behave this way. Go's minimal version selection uses the lowest version that satisfies every requirement, so a new upstream release changes nothing until someone edits go.mod. Go's own description of the design, in How Go Mitigates Supply Chain Attacks, says a malicious new release affects no one until they explicitly update that dependency. Range-based resolvers like npm, pip, and Cargo are now retrofitting a weaker version of that property with a release-age cooldown, covered below.
When does a package manager run dependency code?
#A package manager runs dependency code at the earliest stage its defaults allow, and that stage differs by ecosystem: install, build, interpreter startup, or application runtime. To check the JavaScript defaults rather than rely on changelogs, we built a test package as a local tarball whose only behavior was a postinstall script that appends one line to a log file. We then installed it with six package manager versions on October 23, 2026:
| Package manager | Did the postinstall script run? | What the tool reported |
|---|---|---|
| npm 10.9.4 | Yes | Nothing; the script ran silently |
| npm 12.2.1 | No | Warning that the script is not covered by allowScripts; install succeeds |
| pnpm 10.28.0 | No | "Ignored build scripts" warning; install succeeds |
| pnpm 11.28.6 and 12.15.0 | No | ERR_PNPM_IGNORED_BUILDS; install exits with status 1 |
| Yarn 4.18.1 | No | "all build scripts have been disabled" |
| Bun 1.4.4 | No | "Blocked 1 postinstall. Run `bun pm untrusted` for details." |
This is npm 12's output for the test package, including the review command it suggests:
$ npm install
added 1 package in 519ms
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts install-probe@1.0.0 (postinstall: node -e "...")
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.pnpm was first: pnpm 10 blocked dependency lifecycle scripts by default in January 2025, and pnpm 11 turned the warning into a failed install. Approvals are where these managers differ most. npm install-scripts approve esbuild writes "allowScripts": { "esbuild@0.28.2": true } to package.json, pinned to the exact version, so the next esbuild release needs a fresh review. pnpm's allowBuilds setting accepts bare names or version matchers. Bun does not ask at all for packages on its built-in allowlist, which held 367 package names on October 23, 2026, including esbuild, sharp, and nx.
Outside JavaScript, the first execution point depends on the shape of the package format, not on a setting. The figure places each package manager on the timeline from install to application runtime.
The same information as a table, with the mechanism and the control for each package manager, as of October 2026:
| Package manager | Earliest default execution | Mechanism | Control |
|---|---|---|---|
| npm 11 and earlier | Install | preinstall, install, postinstall; implicit node-gyp build when binding.gyp exists | --ignore-scripts |
| npm 12+ | Runtime, unless approved | Same scripts, now blocked until approved | allowScripts, pinned to name@version |
| pnpm 10+ | Runtime, unless approved | Dependency lifecycle scripts blocked; pnpm 11 fails the install | allowBuilds (pnpm 11+) |
| Yarn 4.14+ | Runtime, unless approved | enableScripts defaults to false | dependenciesMeta per package |
| Bun | Install, for allowlisted names | Lifecycle scripts run only for the default list or trustedDependencies | trustedDependencies (replaces the list) |
| pip, uv: sdist | Install | The build backend (for example setup.py) runs to produce a wheel | pip --only-binary :all:, uv --no-build |
| pip, uv: wheel | Interpreter startup | No code at install; .pth files in site-packages run at every Python start | Content review; no installer flag |
| Cargo | Build | build.rs and procedural macros run during cargo build, check, and test | Review; vendoring |
| Go | Runtime or tests | Fetching and building never run dependency code | None needed for install |
| Maven | Build | Build plugins run during the lifecycle | Plugin review; JDK 23+ disables implicit annotation processing |
| gem, Bundler | Install | Native extensions run extconf.rb | Review |
| Composer | Runtime, or install for allowed plugins | Plugins need allow-plugins (since 2.2); autoload.files run on every request | allow-plugins |
| NuGet | Build | build/*.props and *.targets are imported into MSBuild | Review |
Go is the cleanest case. Filippo Valsorda wrote on the Go blog in March 2022 that in Go "neither fetching nor building code will let that code execute, even if it is untrusted and malicious." Most ecosystems cannot make that claim because their package formats can carry build logic. Rust crates ship build.rs, Ruby gems ship native extension scripts, NuGet packages ship MSBuild targets that run inside your build, and javac ran annotation processors found on the classpath automatically until JDK 23. Python is the subtle case. Installing a wheel runs no package code, but any .pth file it places in site-packages can contain an import line that Python executes at every interpreter start.
We verified the Python, Rust, and Ruby rows on October 23, 2026 with pip 26.2.2, uv 0.11.32, Cargo 1.97.0, and RubyGems 3.5.22. cargo fetch ran nothing and cargo check ran the build script. gem install ran the extension script. Installing from a source distribution ran setup.py under both pip and uv, while --only-binary :all: and --no-build refused the source distribution outright.
Blocking install scripts moves the attack instead of ending it
#Attackers follow the default. When scanners and policies learned to watch preinstall and postinstall, the Miasma Mini Shai-Hulud campaign switched to what our researchers call the "Phantom Gyp" technique. The malicious packages declared no lifecycle scripts at all. They added a binding.gyp file, which older npm versions treat as a native addon and build automatically with node-gyp, so the payload ran during install while package.json looked clean. npm 12 closed that path too, by putting the implicit node-gyp build behind the same approval as explicit scripts. As of October 2026, Socket's campaign page lists 522 affected artifacts across 172 packages for that campaign alone.
The same campaign shows the pattern in other ecosystems. Its Hades PyPI wave shipped 37 malicious wheels across 19 packages, and each one carried a .pth file that runs at Python startup instead of an install hook. In Rust, the arrayref, internment, and append-only-vec compromise in August 2026 added a typosquatted dependency whose build.rs downloaded a backdoor during cargo build. In PHP, the Laravel Lang compromise in May 2026 backdoored more than 700 historical versions through a file registered under autoload.files, which Composer's autoloader loads on every request.
None of those four needed an install script. The install scripts default still matters, because install time is where most supply chain attacks have executed and closing it shrinks the window between download and execution. But a dependency's code exists to be run. If the package manager will not run it during install, your build, your test runner, or your application will run it a few seconds later.
Allowlists have the same limit in a different place. Any approval keyed by name alone trusts every future version of that package. The August 2025 Nx compromise is the example: according to the Nx advisory, the malicious releases contained "a postinstall script that scanned user's file system for text files." A name-level approval for nx would have run it. npm 12's version-pinned approvals avoid that by default, at the cost of a review prompt on every upgrade of a package with scripts.
This is why we analyze what is in a package, not what its metadata claims. The Phantom Gyp packages declared no install script in package.json; npm inferred one from the presence of binding.gyp, and scanners that read only the preinstall and postinstall fields missed them. No metadata field announces a .pth file inside a wheel or an autoload.files entry that phones home. The signal lives in the files.
Which package manager defaults matter for security?
#The package manager defaults that matter for security are three: whether dependency scripts run without approval, whether fresh releases are eligible for resolution, and whether dependencies can come from outside the registry. As of October 2026, the JavaScript managers have moved furthest on all three:
| Package manager | Dependency scripts by default | Release-age delay by default | Git and URL dependencies |
|---|---|---|---|
| npm 12.2.1 | Blocked until approved (warning) | Off; min-release-age is opt-in | Blocked by default (allow-git, allow-remote = none) |
| pnpm 11 and 12 | Blocked; install fails until approved | 1,440 minutes (since 11.0) | Exotic transitive sources blocked (blockExoticSubdeps) |
| Yarn 4.18.1 | Blocked (enableScripts: false since 4.14.0) | 1 day reported by 4.18.1 (npmMinimalAgeGate, since 4.15.0) | Git blocked unless listed in approvedGitRepositories |
| Bun 1.4.4 | Blocked except a 367-name allowlist | Off; minimumReleaseAge is opt-in | Allowed |
| pip 26.2, uv 0.11 | Source builds run by default | Off; --uploaded-prior-to, --exclude-newer | Allowed |
| Cargo 1.97 | build.rs runs at build | Off; stable in Rust 1.100, still opt-in | Allowed |
| Bundler 4.0.13+ | Extensions run at install | Off; cooldown is opt-in | Allowed |
The sources for the newest rows: pnpm 11 made the 24-hour delay and blocked exotic subdependencies the default in April 2026; Yarn's 4.14.0 and 4.15.0 releases changed its script and age-gate defaults; Cargo's cooldown is scheduled to stabilize in Rust 1.100 on November 12, 2026 with a default of zero; and Bundler's cooldown arrived in 4.0.13. Yarn's current documentation lists a one-week default for npmMinimalAgeGate, while 4.18.1 reported one day when we ran it, so check the value your version actually uses.
Our verdict: turn on script blocking everywhere it exists, and approve by exact version where the tool allows it. Turn on a release-age delay of at least one day for third-party packages, with an exclusion for your own internal packages. Refuse Git and URL dependencies unless a specific one is reviewed, because they skip registry-side checks entirely; see Git and URL dependencies.
Each of those defaults has a cost. A cooldown buys detection time, and the arrayref releases were live for only 86, 90, and 107 minutes before removal, so even a day would have kept them out. But a cooldown also delays legitimate security fixes, and a malicious release that escapes detection simply outlasts it. Script blocking breaks native modules quietly: a skipped node-gyp build lets the install succeed and fails at runtime when the module loads. Version-pinned approvals generate a review prompt on every upgrade, and reviewers who see a prompt for every release learn to approve without reading.
Where package manager guarantees stop
#A package manager's verification answers one question: did I receive what the registry published? Integrity hashes, registry signatures, and provenance all answer it well. None of them answers whether the published code is benign. That gap matters most when a real maintainer's credentials publish a malicious release, because the result passes every check. The Miasma waves came from compromised maintainer accounts publishing through legitimate pipelines, some with valid provenance. Provenance is necessary, and it is not sufficient.
Closing that gap means checking content before it executes, and the package manager is the natural place to do it, because it is the only component that sees every package before any of its code runs. Some tools now expose that hook. Bun 1.3 added a security scanner API that calls a provider before installation, and Socket integrates with it. For other package managers, Socket Firewall wraps the command (sfw npm install, sfw pip install, sfw cargo build) and runs it through an ephemeral HTTP proxy that checks each requested artifact against Socket's analysis and blocks known-malicious ones before download. It has limits: it sees only network fetches, so artifacts already in a local cache are not checked, and the free version does not block packages Socket has not yet scanned. The general category, with its trade-offs, is the package firewall.
The open problem is that no such hook is standard. Each ecosystem has invented its own approval file, its own cooldown flag, and its own idea of what counts as a script, and most of them still default to running build logic and taking the newest version. Until package managers share a pre-execution policy interface, the safety of an install depends on which tool you happened to type, and on whether its defaults were set before or after the last campaign.
