Back to glossary

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, or go.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:

LanguagePackage managersDefault registryManifestLockfile
JavaScript, TypeScriptnpm, pnpm, Yarn, Bunnpm registrypackage.jsonpackage-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock
Pythonpip, uv, PoetryPyPIpyproject.toml, requirements.txtuv.lock, poetry.lock, pylock.toml
RustCargocrates.ioCargo.tomlCargo.lock
Gogo commandGo module proxy and checksum databasego.modgo.sum (hashes; go.mod already pins versions)
Java, KotlinMaven, GradleMaven Centralpom.xml, build.gradleGradle lockfiles (opt-in); none in Maven
Rubygem, BundlerRubyGems.orgGemfileGemfile.lock
PHPComposerPackagistcomposer.jsoncomposer.lock
.NETNuGetnuget.org.csprojpackages.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:

StepWhat the package manager trustsAttack that targets it
1. Read the manifestThe package names you typedTyposquatting and hallucinated package names
2. Resolve versionsRegistry metadata; for range resolvers, the newest version in rangeA malicious version published into a popular range; dependency confusion between private and public names
3. Fetch archivesThe registry or mirror serving the fileRare in practice; registries serve over TLS from a CDN
4. Verify integrityThat bytes match the registry's hashNone needed: a compromised maintainer's upload has a valid hash
5. Write files to diskThe archive's full contentsFiles that no manifest field declares, such as startup hooks
6. Run dependency codeEvery package allowed to run scriptsInstall scripts, native build hooks, build scripts
7. Write the lockfileThe result of steps 1 to 6Nothing; 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:

Bash
$ 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:

Bash
$ 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 managerDid the postinstall script run?What the tool reported
npm 10.9.4YesNothing; the script ran silently
npm 12.2.1NoWarning that the script is not covered by allowScripts; install succeeds
pnpm 10.28.0No"Ignored build scripts" warning; install succeeds
pnpm 11.28.6 and 12.15.0NoERR_PNPM_IGNORED_BUILDS; install exits with status 1
Yarn 4.18.1No"all build scripts have been disabled"
Bun 1.4.4No"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:

Plain text
$ 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 managerEarliest default executionMechanismControl
npm 11 and earlierInstallpreinstall, install, postinstall; implicit node-gyp build when binding.gyp exists--ignore-scripts
npm 12+Runtime, unless approvedSame scripts, now blocked until approvedallowScripts, pinned to name@version
pnpm 10+Runtime, unless approvedDependency lifecycle scripts blocked; pnpm 11 fails the installallowBuilds (pnpm 11+)
Yarn 4.14+Runtime, unless approvedenableScripts defaults to falsedependenciesMeta per package
BunInstall, for allowlisted namesLifecycle scripts run only for the default list or trustedDependenciestrustedDependencies (replaces the list)
pip, uv: sdistInstallThe build backend (for example setup.py) runs to produce a wheelpip --only-binary :all:, uv --no-build
pip, uv: wheelInterpreter startupNo code at install; .pth files in site-packages run at every Python startContent review; no installer flag
CargoBuildbuild.rs and procedural macros run during cargo build, check, and testReview; vendoring
GoRuntime or testsFetching and building never run dependency codeNone needed for install
MavenBuildBuild plugins run during the lifecyclePlugin review; JDK 23+ disables implicit annotation processing
gem, BundlerInstallNative extensions run extconf.rbReview
ComposerRuntime, or install for allowed pluginsPlugins need allow-plugins (since 2.2); autoload.files run on every requestallow-plugins
NuGetBuildbuild/*.props and *.targets are imported into MSBuildReview

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 managerDependency scripts by defaultRelease-age delay by defaultGit and URL dependencies
npm 12.2.1Blocked until approved (warning)Off; min-release-age is opt-inBlocked by default (allow-git, allow-remote = none)
pnpm 11 and 12Blocked; install fails until approved1,440 minutes (since 11.0)Exotic transitive sources blocked (blockExoticSubdeps)
Yarn 4.18.1Blocked (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.4Blocked except a 367-name allowlistOff; minimumReleaseAge is opt-inAllowed
pip 26.2, uv 0.11Source builds run by defaultOff; --uploaded-prior-to, --exclude-newerAllowed
Cargo 1.97build.rs runs at buildOff; stable in Rust 1.100, still opt-inAllowed
Bundler 4.0.13+Extensions run at installOff; cooldown is opt-inAllowed

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.