# 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

Source: https://socket.dev/glossary/package-manager

Last updated: 2026-10-23

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](https://socket.dev/blog/npm-12) 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](https://socket.dev/glossary/transitive-dependency) 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](https://socket.dev/glossary/npm-registry) or [PyPI](https://socket.dev/glossary/python-package-index-pypi).
- **A resolver** that picks one concrete version per package so that every declared range is satisfied, usually written as [semantic versioning ranges](https://socket.dev/glossary/semantic-versioning-semver) 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](https://socket.dev/glossary/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](https://socket.dev/glossary/npm-node-package-manager) is both a command-line client and a registry, and [pnpm](https://socket.dev/glossary/pnpm), [Yarn](https://socket.dev/glossary/yarn-package-manager), and [Bun](https://socket.dev/glossary/bun) are separate clients that install from the same npm registry. Several clients sharing one registry is common: [pip](https://socket.dev/glossary/pip-python-package-index), [uv](https://socket.dev/glossary/uv), and [Poetry](https://socket.dev/glossary/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](https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html) 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](https://socket.dev/glossary/malicious-package).

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](https://socket.dev/glossary/crates-io) for Rust, [Go modules](https://socket.dev/glossary/go-modules) for Go, [Maven Central](https://socket.dev/glossary/maven-central) for the JVM, [RubyGems](https://socket.dev/glossary/rubygems) for Ruby, [Packagist](https://socket.dev/glossary/packagist) for PHP, and [NuGet](https://socket.dev/glossary/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.

> Highlighted steps are where most supply chain attacks take effect: a malicious version enters at resolution and runs at the execution step.
>
> The seven steps of a package manager install and what each step trusts. 1 Read manifest: trusts the names you typed. 2 Resolve versions: trusts registry metadata and whatever is newest in range. 3 Fetch archive: trusts the registry CDN. 4 Verify integrity: proves bytes match what the registry published, not that they are safe. 5 Write to disk. 6 Run scripts: executes dependency code if the package manager allows it. 7 Write lockfile: records the result for the next install.

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](https://socket.dev/glossary/typosquatting). An internal package name that also exists on a public registry can resolve to the public copy, which is [dependency confusion](https://socket.dev/glossary/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](https://socket.dev/glossary/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](https://docs.npmjs.com/about-registry-signatures). `attestations` links a provenance statement saying which CI workflow built the tarball, covered in our entry on [package provenance](https://socket.dev/glossary/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](https://socket.dev/blog/axios-npm-package-compromised) 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](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) 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](https://go.dev/blog/supply-chain), 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](https://socket.dev/glossary/dependency-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:

```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](https://socket.dev/blog/pnpm-10-0-0-blocks-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](https://pnpm.io/settings/build) 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](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt) 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.

> \* Bun runs install scripts by default only for the packages on its built-in allowlist. Defaults as of October 23, 2026; the shaded column is where the JavaScript managers changed in 2025 and 2026.
>
> Earliest point at which each package manager runs dependency code, as of October 2026. npm 11 and earlier: install. npm 12, pnpm 10 and later, Yarn 4.14 and later: install only for approved packages, otherwise app runtime. Bun: install for its default allowlist of popular packages, otherwise app runtime. pip and uv building an sdist: install, plus interpreter start. pip and uv installing a wheel: interpreter start via .pth files. Cargo: build. Go: app runtime only. Maven: build. RubyGems and Bundler: install. Composer: install only for allowed plugins, otherwise app runtime. NuGet: build.

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](https://learn.microsoft.com/nuget/concepts/msbuild-props-and-targets), and javac ran annotation processors found on the classpath automatically until [JDK 23](https://www.oracle.com/java/technologies/javase/23-relnote-issues.html). Python is the subtle case. Installing a [wheel](https://socket.dev/glossary/wheel-python-packaging-format) 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](https://socket.dev/supply-chain-attacks/miasma-mini-shai-hulud-supply-chain-attack) campaign switched to what our researchers call the ["Phantom Gyp" technique](https://socket.dev/blog/miasma-mini-shai-hulud-hits-leoplatform-npm-packages-go-ecosystem). 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](https://socket.dev/blog/shai-hulud-descends-to-hades-miasma-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](https://socket.dev/blog/popular-rust-crates-compromised) in August 2026 added a typosquatted dependency whose `build.rs` downloaded a backdoor during `cargo build`. In PHP, the [Laravel Lang compromise](https://socket.dev/supply-chain-attacks/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](https://socket.dev/glossary/postinstall-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](https://socket.dev/blog/nx-packages-compromised) is the example: according to the [Nx advisory](https://github.com/nrwl/nx/security/advisories/GHSA-cxm3-wv7p-598c), 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](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) made the 24-hour delay and blocked exotic subdependencies the default in April 2026; Yarn's [4.14.0 and 4.15.0 releases](https://github.com/yarnpkg/berry/releases) changed its script and age-gate defaults; [Cargo's cooldown](https://socket.dev/blog/rust-cargo-cooldowns) is scheduled to stabilize in Rust 1.100 on November 12, 2026 with a default of zero; and [Bundler's cooldown](https://socket.dev/blog/rubygems-adds-bundler-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](https://socket.dev/glossary/git-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](https://socket.dev/blog/socket-integrates-with-bun-1-3-security-scanner-api). For other package managers, [Socket Firewall](https://socket.dev/blog/introducing-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](https://socket.dev/glossary/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.
