Package Manager Threat Model
This page is the maintained version of two posts from May 2026, Package Manager CWEs and Package Manager Threat Models, merged and updated. Changes since then are summarised in a separate post. Last reviewed September 2026.
Going through every security advisory filed against a package manager, client and registry both, produces about twenty recurring patterns. Most tools have entries under at least half of them, often years apart, because the people building package manager number nineteen often miss what bit package managers one through eighteen. Running the same list against ten package managers as an audit instrument over the following months produced findings under most of the same headings again, plus a handful the list was missing.
Part I is bugs you can point at a line number and patch: path traversal in the extractor, argument injection in the git driver, XSS in the README renderer. Part II is properties that are working as designed and so stay outside the CVE record, which are also where almost every supply-chain incident with a name actually came from: in event-stream, ua-parser-js, left-pad, and xz, the package manager behaved exactly as designed.
Running both against the same code and letting them overlap is more useful than either alone. The CWE list catches mechanical clusters (four separate predictable-tempfile sites, six separate places a credential ends up in a string) that the design questions walk past. The design questions reach composition bugs, two features whose trust assumptions differ at the seam, that a pattern scanner misses because the fault is in the composition rather than any single call. When several entries independently converge on the same file, that convergence is a stronger signal than any one of them firing. Each entry is tagged client, registry, or both so an auditor knows which side to read.
Trust boundary
The first output of an audit is a statement of what the tool places inside the trust boundary, what it places outside, and what is out of scope. Everything after is graded against that boundary; it’s what separates a bug from an accepted design decision or a non-issue.
A typical boundary names, on the trusted side: the maintainer team acting through reviewed changes, the hosting and identity provider, the base OS. On the untrusted side: upstream project authors, download servers, third-party repository authors, anyone submitting a pull request, network attackers. And explicitly out of scope: an attacker who already controls the user’s account, shell, environment, or working directory, since the tool’s guarantees end once those are compromised.
Several findings in this document are only findings because of where the boundary is. A build system that runs dependency code with full user privileges places, by design, a dependency author inside the boundary; the interesting questions are then what fires before that point, and whether the same tool offers a declarative manifest format that keeps an author outside it.
Part I: Weakness patterns
CVE counts measure what got reported and assigned an ID, which is a different thing from risk. Some projects run bug bounties and file a CVE for every ANSI escape; others fix the same thing in a point release with a changelog line. And the design-level weaknesses in Part II are largely absent from the CVE record precisely because they’re working as intended.
Path traversal — client
CWE-22, with CWE-59 (link following) for the symlink variants and CWE-73 for the general case. The single most common entry in the dataset, recurring for twenty years.
The 2018 Zip Slip research put a name on the archive-entry version: a tar or zip member with ../ in its path, or a symlink pointing outside the extraction root, or a Windows backslash the sanitiser treats as a literal. That variant has been hitting tools since at least CVE-2007-0469 and continues (GHSA-9rww-v4mm-x4jg, GHSA-m37j-52j7-pjw7). Each variant needs its own fix (.. segments, symlinks separately for tar and zip, backslashes on Windows) and several tools have three or four CVEs here because each fix was partial.
In the advisory record collected here, the archive entry is the minority case. Most of the newer advisories are a manifest field that becomes a path component: the package name (GHSA-499r-g7pc-vmp9, GHSA-vq4v-j7r6-jq4m), a bin entry (CVE-2019-16775, GHSA-gjfg-22fp-rrxx and its bypass five weeks later), an entry-point script name (GHSA-4gg8-gxpx-9rph, GHSA-wf93-45jw-7689, GHSA-78v8-vpjp-cjqh, GHSA-9m8m-c4j3-rj2c, the same bug filed against four Python installers within five weeks), a version string (CVE-2023-35946, GHSA-vmx8-mqv2-9gmg), a lockfile alias (GHSA-hwx4-2j3j-g496), a patch file target header (GHSA-rxhj-4m44-96r4), a Content-Disposition filename (CVE-2019-20916), a DSL stanza that names a file to move or rename (GHSA-7hjr-9j5m-v2hw), or a hash used as a cache directory name. Anywhere a string from a package or a server ends up in a filesystem path.
Extract into a directory and refuse to create anything whose resolved path falls outside it. Resolve symlinks before checking, including chains where an earlier archive member creates a symlink a later member writes through. Check hardlink targets. Treat every separator the target OS treats as a separator. Test “under it” at a path-component boundary rather than as a string prefix, since /cache/foo-evil starts with /cache/foo yet is outside it. And note that most languages’ path-join primitive returns the right-hand side unchanged when it’s absolute, so join(safe_dir, user_string) with user_string = "/etc/passwd" discards safe_dir entirely.
Argument injection — client
CWE-88, occasionally filed as CWE-77 or CWE-78. Nearly every package manager can install from a VCS URL, and nearly every one has at some point passed a user-controlled string to git clone, hg clone, svn checkout, or p4 in a way that let it be interpreted as an option: a ref of --upload-pack=payload, a URL starting with a dash, a branch name of -c core.fsmonitor=…. Examples: CVE-2021-29472, CVE-2022-36069, CVE-2021-43809, CVE-2023-5752, CVE-2022-24440, GHSA-9g4r-vmj2-j2gj, GHSA-rvx4-ffvw-m9q3. One tool has seven CVEs in this category across git, hg, and Perforce drivers, because the fix on one backend leaves the others open.
The git case has a subtlety of its own: git’s -- means “revisions before, pathspecs after” and leaves option parsing active for the revision position, so git checkout $ref -- with a trailing -- leaves $ref unprotected. The marker that does terminate option parsing is --end-of-options, added in git 2.24.0, and as of September 2026 Go’s cmd/go is the only package manager that uses it. CVE-2025-68119 is the demonstration: -- was added to cmd/go in 2019 and the ref position was still exploitable in 2025.
A related cluster is compiler flag injection, a package smuggling -fplugin= or arbitrary LDFLAGS through to the C compiler at build time: CVE-2018-6574, CVE-2023-29404, CVE-2023-39323. Same pattern, different subprocess.
Implementing the git wire protocol in-process, or linking libgit2 or JGit, removes the argument-injection surface. It also means the checkout code has to reimplement every path-safety fix upstream git has accumulated: .git directory protection on case-insensitive filesystems, NTFS short names and alternate data streams, submodule paths that resolve through symlinks. libgit2 and JGit have both had rounds of these (CVE-2020-12278, CVE-2020-12279, CVE-2023-4759), usually shortly after the equivalent fix landed in git itself, so a tool with its own git implementation takes on tracking that advisory stream indefinitely.
Integrity checks that fail open — client
CWE-345 (insufficient verification of data authenticity) and CWE-347 (improper verification of cryptographic signature). A missing signature file causes verification to be skipped (GHSA-q5jf-9vfq-h4h7), or an error from the checker is treated as success (CVE-2022-31156), or an unparseable signature counts as valid (CVE-2012-6088), or one code path bypasses a check every other path applies (CVE-2016-1252, GO-2026-4984). The transparency-log variant is a client accepting hashes the log never vouched for (GO-2026-6179, GO-2026-6180).
The early-2010s version was simpler: no TLS, or TLS without certificate checks, or following an HTTPS-to-HTTP redirect (CVE-2012-2125, CVE-2013-1629, CVE-2013-0253). That’s mostly fixed now, though the same gap survives on less-examined transports (CVE-2022-46176 is missing SSH host-key verification, GHSA-7699-qf8c-q47m is a POST download strategy that discarded the redirect-protection state) and several tools still accept plain http:// or a downgrade redirect silently. If there’s a verification step, write the tests where the signature is absent, malformed, and valid-but-for-different-content, and make sure all three refuse to install.
Credential leakage — client
CWE-522 (insufficiently protected credentials), also filed as CWE-201 and CWE-532.
An auth token scoped to one host is sent to another: on a redirect (CVE-2019-15052, GHSA-3m5g-jfx7-3p65), to a mirror or a dependency host (CVE-2021-32690, GHSA-w8pw-h853-frw2), to any registry regardless of scope (CVE-2016-3956, GHSA-p688-r7jv-fm6f), or to a third-party server the user is publishing to (CVE-2021-22568). A variant that has become common enough to name is the client following a server-supplied Location, Link, or WWW-Authenticate realm header to a host the server chose and sending credentials there: GHSA-vh4v-2xq2-g5cg, GHSA-xf85-363p-868w, and four Renovate advisories for the Link pagination header across four different registry types. The server supplies a URL and the client fetches it with credentials attached, which is the client-side dual of SSRF.
Credentials also end up somewhere readable: passwords embedded in URLs written to a log (CVE-2020-15095, GHSA-g6xq-892h-64w3), signing passphrases at debug level (CVE-2020-13165), workspace ignore files skipped so secrets get packed into the published tarball (CVE-2022-29244), TLS keys the log sanitiser missed (GHSA-4hmw-qw74-vrhm), or a secret passed on argv where any local process can read it from /proc.
Credentials should be bound to an origin and stripped on cross-origin redirect, the way a browser would, and stripped again when following any server-supplied URL to a different origin. Anything that builds a publishable artifact needs a test that the output excludes .env and its equivalents.
Dependency confusion — client
CWE-427 (uncontrolled search path element) at the resolver level, also filed as CWE-829. A private package name also exists on the public registry, the resolver consults both, and highest-version-wins means the public one is installed. Or a secondary source in the manifest is allowed to satisfy any dependency instead of only the ones it was added for.
This was reported against individual tools years before it had a name: CVE-2013-0334 and CVE-2016-7954 are the same bug Alex Birsan made famous in 2021. CVE-2020-36327 and CVE-2018-20225 are the post-2021 filings, the second disputed as documented behaviour. CVE-2022-21668 is the nastier variant where an --index-url in a requirements file comment redirects every subsequent install to an attacker’s index.
Related: the resolver falling through to the next configured source when one errors or is unreachable (GHSA-mqwm-5m85-gmcv), a non-release ref treated as a release (CVE-2022-23773), or a substring match where an exact match was needed so an unrelated package captures a name.
Every dependency should resolve from exactly one source and that source should be recorded in the lockfile. If a source is unreachable, fail rather than try the next one.
Local files trusted as config — client
CWE-426 (untrusted search path) and CWE-427 again. Running the tool inside an untrusted directory, a fresh clone, a downloaded tarball, a mounted volume, and having it act on an executable, a config file, or a search path from that directory before the user has agreed to anything. CVE-2021-4435 and CVE-2021-3115 ran binaries from .; CVE-2023-39320 let a toolchain directive in the module file point at a bundled binary; CVE-2024-24821 loaded PHP from the project’s vendor/ into the tool’s own process; GHSA-qq6c-99pv-prvf executed project-local plugins before the CLI had parsed which command was being run.
The newer advisories in the record are dominated by project-local configuration rather than executables: a .npmrc, .mise.toml, or .config/*/config.toml in the checkout that sets a credential-helper command (GHSA-29hf-rm4x-xxph), a shell interpreter (GHSA-g74g-rg72-j2p3), a template engine hook (GHSA-fjj5-v948-whjj), or an install-engine selector (GHSA-gj8w-mvpf-x27x), any of which is arbitrary command execution. A related pattern is a config file in a build-output or gitignored directory (.lake/, node_modules/.bin/, .git/config) that stays hidden from a diff and takes effect ahead of the committed one.
The user’s mental model is that install is the dangerous command and status, lock, audit, env, --list-steps, and opening the folder in an editor are safe; the CVEs land where that model is wrong. This is the CWE-record half of the code-execution-before-install design question.
Shared filesystem locations — client
CWE-377 (insecure temporary file), CWE-276 (incorrect default permissions), CWE-362/367 (TOCTOU). Predictable paths under /tmp, world-writable cache or install directories, files extracted with the archive’s mode bits instead of the user’s umask. Another local user plants a file before the tool gets there, or modifies one after it’s been put down.
CVE-2019-3881, CVE-2021-29428, and CVE-2023-38497 are language-package-manager examples. The system package managers have a longer history because they run as root: a symlink or hardlink swap between “installer creates the file” and “installer sets its permissions” is a privilege escalation, and the same tool can accumulate several of these over a decade as each fix turns out to be incomplete (CVE-2017-7500, CVE-2021-35937, CVE-2021-35939). The pattern also applies to any component of a single-user tool that crosses a privilege boundary, typically a .pkg postinstall or an installer script that runs under sudo and reads from a location the unprivileged user can write (GHSA-59v8-x8q4-px5c, GHSA-wh9r-xjmq-6v2h). Use per-process temp directories from mkdtemp, set permissions at creation time, and where root is involved use fd-relative operations so the thing checked is the thing modified.
Unsafe deserialisation — both
CWE-502 (deserialisation of untrusted data). Package metadata parsed with a YAML or marshalling library that can instantiate arbitrary objects: CVE-2017-0903 is a gemspec parsed with the unsafe YAML loader, CVE-2025-32798 is a recipe selector evaluated as Python. The XML twin is CWE-611 (external entity reference): Java-ecosystem manifests are XML, the platform’s default parser resolves external entities, and a <!DOCTYPE> in a POM or ivy.xml reads local files off the resolving machine (CVE-2022-46751, CVE-2023-42445).
Small in count, high in severity, because they mean code execution or file disclosure from metadata alone before the user has agreed to install anything. Registries parse the same manifests with the same libraries, so a client-side deserialisation bug is usually a registry-side one too. Parse metadata with loaders that return plain data (safe_load and its equivalents, JSON, TOML) and turn off external entity resolution on every XML parser that touches a manifest.
Resource exhaustion — both
CWE-400 (uncontrolled resource consumption), CWE-770, CWE-1333 (ReDoS). A compressed archive that expands to fill the disk (CVE-2022-36114), a version string or URL that hits exponential backtracking in a regex (CVE-2013-4287, CVE-2025-8262), a JSON Schema $ref chain deep enough to exhaust memory (CVE-2025-55199), a tar header with a negative size that loops forever (CVE-2018-1000075), or a length field read straight into an allocation.
Mostly a nuisance on a developer machine, more interesting on a shared CI runner or a registry that processes uploads. The registry case has a distinct sub-pattern of a per-file size guard that’s bypassed by aggregation, so many small files sum past the limit, or a size check applied on one upload path and skipped on another (GHSA-5xcq-p92v-43rf). Cap extracted size and recursion depth, set a total-transfer timeout, and lint the regexes that touch package metadata.
Terminal escape sequences — client
CWE-150. Package name, description, or error text is printed straight to the terminal, ANSI escapes and all. Lets a malicious package rewrite earlier output, forge a success line, hide what was actually installed, or in some terminal emulators do worse. At least nine CVEs across four ecosystems (CVE-2017-0899, CVE-2021-21303, CVE-2025-67746), plus an HTML-output variant where a build report rendered attacker-controlled feature names without escaping (CVE-2023-40030). Strip control characters from anything that came from a package before it reaches stdout, including subprocess stderr and HTTP error bodies that get logged.
Lockfile bypass — client
Usually filed under CWE-345 or CWE-353. The lockfile exists, the user believes installs are reproducible, and some code path ignores it. CVE-2021-43616 installed even when the lockfile and manifest differed; GHSA-7vhp-vf5g-r2fw let remote dynamic dependencies through a frozen lockfile; GHSA-hg3w-7f8c-63hp left the tarball hash out of the lockfile for one dependency type; CVE-2019-15608 checked a hash on the way into the cache and skipped the check on the way out. Related: a lockfile format that records the right field, a content digest, an expiry, a source URL, and an install path that skips it. Several audits found a digest per package in the lockfile schema and an install command that re-resolves from the manifest and overwrites the lockfile with whatever came back.
The parser-differential variant: two tools, or a tool and an auditor, read different contents from the same archive. CVE-2023-37478 is a tarball one installer reads differently from others while matching the same lockfile hash; GHSA-58qw-9mgm-455v is a file that’s both a tar and a zip depending on which end you read from, so the security scanner and the installer see different packages. The test is to enumerate every code path that ends with package bytes on disk and confirm each one consults the lock, and to hash and extract with the same parser so both operations read the same contents.
Protection mechanism bypass — client
CWE-693. For tools that isolate builds from the host, the bugs are in the isolation: passing file descriptors between builds over Unix sockets (CVE-2024-27297), privilege dropping that left privileges intact on one platform (CVE-2025-53819), fetchers that run outside the sandbox the build runs in, LaunchServices as an escape route on macOS (GHSA-5263-whxq-77hp). The sandbox-escape count is small only because most package managers run builds unsandboxed, which moves the risk out of the CVE record and into Part II.
The same CWE covers mechanisms other than sandboxes: an install-script allowlist a package can spoof its way onto (GHSA-5wx6-mg75-v57r), a tap allowlist a git redirect can bypass (GHSA-r9gp-p4vv-f93x), an anti-downgrade check an unprivileged user can skip (GHSA-q4gr-vc25-57m5), a minimum-release-age gate one update type skips (GHSA-g4qr-hw2h-687r). The common test is: for each configurable protection, find the code paths that should consult it and check each one does.
A sandbox has a second boundary that’s easy to miss: what the sandboxed process returns to the unsandboxed one. A build that runs confined and then writes an install manifest, a step list, or a cache entry that the tool acts on with full privileges has moved the trust decision to whatever validates that hand-off. Absent that validation, the sandbox constrains the build while its output runs with full privileges.
Weak cryptographic parameters — both
CWE-326 (inadequate encryption strength), CWE-327, CWE-338. A truncated hash used for integrity, a non-cryptographic hash where collision resistance matters, a token generated with a non-cryptographic RNG (the 2020 crates.io case), or a low iteration count. The parameter is present, the check runs, and the parameter is small enough that the check offers little protection. The fix is current parameters: a full-length cryptographic hash, a CSPRNG for anything secret, and an iteration count that gets revisited rather than set once.
Unauthenticated build daemon — client
CWE-306. A long-running build server that the CLI or editor connects to over a local TCP port or socket with no authentication, so any local process (or, if bound to a non-loopback interface, any network peer) can send it build instructions. GHSA-943m-f264-54p4 is the sbt BSP case; the Gradle daemon and Bazel server have had equivalents. A local socket can be authenticated: permissions on the socket path, a 0600 cookie file the client reads and presents, or a per-session token exchanged at connect. A TCP listener should bind loopback and still authenticate, since every local process can reach it either way.
Memory corruption — client
CWE-787 (out-of-bounds write) and neighbours. Specific to tools written in C with their own ar, tar, cpio, or header parsers: integer overflows on length fields, off-by-one on magic strings, format-string bugs in error paths (CVE-2020-27350, CVE-2014-8118, CVE-2015-0860). About a dozen across the system package managers. If a memory-safe language or a well-fuzzed library is available for the archive layer, use it.
Publishing to someone else’s package — registry
CWE-285 (improper authorisation) and CWE-863. The registry-side bug that matters most, because it turns into a supply-chain compromise while every maintainer account stays intact. CVE-2022-29176 was a name-parsing edge case that let anyone yank and replace any package with a dash in its name; CVE-2022-29218 let the platform-specific variant of someone else’s release be overwritten in the CDN by exploiting upload ordering; CVE-2024-38368 was an orphaned-ownership API left in place for a decade; GHSA-vjgw-2g79-p7w7 is a trailing slash on the package name bypassing the access rules. One registry had an authorisation gap that let anyone publish a new version of any package, reported and fixed outside the CVE process.
The self-hosted registries have a parallel set where the role check tests the wrong object: self-registration accepts an admin: true field from the request body (CVE-2019-16097), a low-privilege user can reach an endpoint that confers admin (CVE-2024-4142), a HEAD on a blob is authorised as read but creates the repository (GHSA-c7hr-vm67-mm6v), or an org admin can escalate to owner through a transfer (GHSA-wj84-52wx-4c26).
The publish, yank, and transfer-ownership endpoints are the ones to review hardest. Every input to “which package is this acting on” is attacker-controlled.
Account takeover — registry
CWE-287 (improper authentication). The practical version is a maintainer’s email domain expiring: someone registers it, requests a password reset, and publishes. This has happened on at least three major registries (the ctx incident is the documented one) and is listed as an open threat in npm’s own model. The credential-stuffing variant has also worked: a 2017 sweep found reused passwords gave publish access to about 14% of one registry by download weight.
The code bugs in this category are token hygiene and OAuth flows: a session-validation link an attacker could get clicked by the victim’s mail scanner (CVE-2024-38367), a loose redirect_uri check that sends the authorisation code wherever the attacker put in the link (CVE-2024-22244, GHSA-v852-mrr4-vw6m), an X-Forwarded-For: 127.0.0.1 header bypassing a localhost-only admin check (CVE-2019-9733).
Registries can detect the domain-expiry case before an attacker does, and several now do. Mandatory 2FA on publish closes most of the rest, as long as the tokens that bypass 2FA are actually scoped.
Stored XSS — registry
CWE-79. The registry renders a README, description, homepage link, or profile field from a package upload, and the sanitiser misses something: javascript: URIs in autolinks (CVE-2024-37304), attribute handling in descriptions (CVE-2024-47604), README content unescaped (GHSA-78j5-gcmf-vqc8). A common blind spot is a style attribute allowed for syntax highlighting with no CSS property allowlist, which permits position: fixed overlays and, without a CSP img-src, background-image: url() beacons.
Higher stakes than the average web XSS because the victim is logged in as someone with publish rights and the page they’re viewing was put there by an attacker after those rights. Render package-supplied content with a real sanitiser and a CSP that limits the damage when the sanitiser eventually misses one.
Server-side code execution — registry
CWE-94 (code injection), and often literally the same bug as the client. Registries parse manifests and clone repositories with the same code the client uses, so a YAML deserialisation bug in the gem library is RCE on the registry as well as on developer machines, and an argument injection in the hg driver is command execution on packagist.org as well as locally. CVE-2024-38366 is a registry shelling out during email validation. Every client-side parsing or VCS bug above is worth checking on the server too; the registry is the higher-value target and it processes the same untrusted input.
SSRF — registry
CWE-918. The registry fetches a URL the user provided (a webhook, a repository URL, an avatar, a remote-mirror config) and that URL points at the metadata service, an internal admin port, or another tenant’s endpoint. CVE-2020-13788 is a “test webhook” button, CVE-2022-27907 a remote-repository config, CVE-2020-29436 an XXE reaching internal hosts. Outbound fetches from user-supplied URLs need a DNS-rebinding check and a block on private and loopback ranges, applied after redirects. Client tools that fetch package-supplied URLs on a CI runner (livecheck, audit --online, autobump) have a version of this too, since the runner may be inside a network with something worth reaching.
IDOR — registry
CWE-639. The request says which project, package, webhook, or robot account to act on, and the server checks that you’re allowed to perform the action while skipping the check on whether you own the object. One self-hosted registry had five of these reported in a single batch (CVE-2022-31666 through -31671); another had a cross-repository blob mount that let a read token pull from a repo outside its scope, and then had the fix for that bypassed twice. Every handler that takes an object ID needs to check ownership, including read-only ones.
Token scope — registry
CWE-863 again, or CWE-1220. An “upload-only” API token that’s session-equivalent when sent as a header (PyPI 2020 disclosure), a read-only key that can be exchanged for a full-scope OAuth token (CVE-2026-21621), a token exchange that grants scopes for organisations outside the principal’s access (GHSA-rfx8-w654-8cpr), automation tokens that bypass 2FA at fixed full scope. The scope printed on the token has to be the scope the server enforces, and every exchange path should preserve or narrow scope.
Shared-cache leakage — registry
CWE-524. A response carrying a credential or per-user data is served without Cache-Control: private, Vary, or an equivalent, and a CDN, reverse proxy, or middleware layer caches it and serves it to the next requester. GHSA-9j48-x3c3-mrp2 is the reference case: the legacy API-key endpoint returned the user’s key, Rack::Deflater was in the middleware stack in an order that dropped the cache headers, and Fastly served one user’s key to another. The bug is in config/application.rb middleware ordering plus the CDN’s default behaviour rather than any controller line. For every response that carries per-user data, check what is between the app and the client (CDN config, reverse-proxy rules, Rack/WSGI middleware order) and what would make it cache.
Manifest confusion — both
The registry stores and displays one set of metadata, the client installs based on another, and an attacker arranges for them to differ. The 2023 npm manifest confusion report is the named example: the publish API accepted a manifest independently of the package.json inside the tarball, so the website, audit tools, and npm install could each see a different dependency list. The Go proxy variant is temporal: the proxy caches the first version of a module path it sees and keeps serving it after the source repository has been cleaned up.
Which clients validate the name embedded in the artifact against the name they asked for, and which rely on registry metadata alone, is a useful thing to tabulate. Of the clients surveyed for this page in 2026, roughly half accept an artifact whose internal name differs from the requested one, sometimes with a warning, sometimes silently; the other half refuse. The answer determines whether a proxy or mirror can transparently substitute one package’s bytes under another’s name and whether the registry’s displayed metadata can lie about what the tarball contains. Validate the artifact’s embedded identity against what was requested, and derive displayed metadata from the artifact itself rather than accepting a separate copy at publish time.
Part II: Design questions
The output of working through these is a few paragraphs per heading describing what the tool actually does, citing the code or configuration that makes each answer true, because the answers differ a lot between tools and most of them are recorded only in the source. A dimension with zero findings still records the invariant that made it safe; those negative results are as useful to maintainers as findings, because they document what the codebase relies on.
Code execution at install time — client
The single biggest design decision, and the one most of the incident record hangs off, is whether install runs code from the package on the user’s machine, with their privileges, before they’ve seen a line of it. Most language package managers do by default: npm runs postinstall, pip runs setup.py, Cargo compiles and runs build.rs, gem runs native extension builds. The mechanism exists for good reasons and it’s also the mechanism behind event-stream, ua-parser-js, node-ipc, colors, and every install-script worm since.
Record which lifecycle hooks exist, which run by default, which run for transitive dependencies as well as direct, what user they run as, whether there’s a flag to turn them off and whether anything works with it set. Then the same for global installs, which on some platforms means root, and for dev/optional dependencies, which some tools install and run hooks for unless told otherwise. Go and Deno are useful reference points because their install step is code-execution-free by design.
If the tool sandboxes install-time code, three more questions. What the sandbox is on each supported platform, and what happens when it’s unavailable: fail closed, or warn and continue. Which install phases run inside it and which run in the main process. And what comes back across the boundary for the unsandboxed side to act on; whatever validates that output is the effective boundary.
Code execution before install time — client
The user’s mental model is usually that install is the dangerous command and everything else is safe. Part I’s local-config entries cover accidental breaks in that model; this section covers the deliberate ones.
A setup.py is a Python program, and for a long time getting the version out of one meant running it. A build.gradle is Groovy and resolving the graph means evaluating it. A lakefile.lean is elaborated with full IO to determine its dependencies. Manifest formats that are data (TOML, JSON, a locked-down YAML subset) draw a hard line here that manifest formats that are programs lack. Enumerate exactly which commands a cautious user can run on an untrusted checkout, since for several tools the honest answer is an empty list, and include:
- Metadata commands:
info,outdated,deps,audit,--list-steps,env - Resolution:
lock,update --dry-run - The language server the editor starts on folder open, and whatever the LSP invokes to read project configuration
- Shell-integration hooks that fire on
cd
The related question is whether files inside the checkout can widen what the tool is allowed to do. Several tools read per-project configuration that sets network access, filesystem scope, plugin paths, credential-helper commands, or which binary to invoke as the toolchain, and if that configuration is honoured on first run without a prompt then a repo can grant itself whatever the format can express. The direnv allow step and VS Code’s workspace-trust prompt exist because their authors made per-project config require explicit approval; check whether the package manager reached the same conclusion for its .npmrc, .cargo/config.toml, mise.toml, wrapper script, or toolchain file, and whether it also honours one from a parent directory or a build-output directory outside where a user would look.
Lockfile guarantees — client
Part I covered lockfile bugs; the design question underneath is what guarantee the lockfile is meant to give. Lockfiles that pin a content hash (go.sum, package-lock.json, Cargo.lock) guarantee the bytes you get are the bytes that were locked. Others pin only a name and version and rely on the registry to keep serving the same bytes for that pair (Gemfile.lock, classic yarn.lock). Several tools have two install commands, one that respects the lock strictly and one that’s allowed to update it; npm install versus npm ci is the pair most people meet first.
Where in the pipeline the hash is checked matters as much as what it covers. A client that downloads, extracts, hashes the extracted tree, and only then compares means every archive-handling bug in Part I is reachable by a mirror or MITM even when the hash ultimately rejects the package. A client that verifies the archive bytes before writing anything to disk avoids that. The same ordering determines whether accepting plain HTTP is defensible; that argument only holds when verification precedes extraction.
Verification before extraction stops a network attacker; the pinned bytes can still be hostile. An upstream author can publish a release tarball containing traversal paths or a crafted archive header, a routine version-bump pins its hash, and the extractor then processes hostile input that matches the reviewed checksum exactly. The hash proves the bytes match what was reviewed; what’s inside them is a separate question.
Record what’s pinned, which commands honour it, whether the CI template uses the strict command, whether verification runs before or after extraction, and whether the on-disk cache is re-verified on read or accepted as-is once written. Go’s checksum database is the most developed answer here: a public append-only log of every module version’s hash that the client verifies against by default, so a version’s resolution stays fixed against both proxy and origin.
Package name identity — both
The rules for when two package names count as equal vary by registry, and nearly all normalise something: case, - vs _ vs ., Unicode width. Client and registry normalisation rules have repeatedly differed on which of those apply, and the space between the two normalisers is where one package can shadow another. Document the client’s rules and confirm they match the registry’s, including for names that arrive via a lockfile or a transitive manifest.
The same question applies on the filesystem. macOS and Windows filesystems are case-insensitive and macOS APFS normalises Unicode, so two names that are distinct to the resolver can collide on disk (GHSA-h35f-9h28-mq5c). A package excluded by one spelling can be included under another, and two dependencies can overwrite each other’s install directory.
Resolution across multiple sources — client
Most clients can be configured with more than one place to fetch from. The 2021 dependency confusion research showed what happens when the same name exists in more than one and the resolver picks by version. pip’s --extra-index-url treating all indexes as equivalent is documented behaviour, and the CVE filed for it was disputed on those grounds.
Determine what the resolver does when a name is satisfiable from more than one configured source: highest version across all of them, first source that has it, explicit per-dependency pinning, or refuse. Whether a source added for one dependency is allowed to satisfy others. Whether the lockfile records which source each package actually came from and whether install honours it.
Mirrors and caching proxies — both
The default corporate configuration puts an Artifactory, Nexus, Athens, or Verdaccio instance between every client and the public registry, and the proxy shifts several of this document’s answers at once. It barely appears in the advisory record because most of what matters about it is deployment configuration rather than code.
Establish which source is authoritative for a name when the proxy carries internal packages and mirrors upstream ones, since the proxy is where dependency confusion is either solved or reintroduced. Whether a yank, a deletion, or a malicious-version flag upstream propagates through the cache, and how fast; a proxy that keeps serving what it cached first extends the Go-proxy persistence case under manifest confusion to every ecosystem it fronts. Whether the proxy re-verifies upstream checksums and signatures, and whether clients behind it still can, or whether the proxy strips the metadata they’d need. Then whether the lockfile records the proxy URL or the origin, which determines whether a lockfile is portable between inside and outside the network. And how the client’s per-origin credential scoping treats the proxy, which terminates one credential and holds a more powerful one of its own.
Namespace allocation — registry
Some tools have no registry: Go modules, Deno, Zig, and Swift packages can resolve straight from a git URL, which relocates most of this section to whoever operates the forge.
For everything else, first-come-first-served is the default, and it means the security of a name rests on whoever registered it in 2011 still being a good actor now. Every known fix would also remove what makes open registries useful, but the policies around the edges vary and matter. Whether a name can be transferred, and who decides. What happens to a name when its owner deletes their account. Whether a deleted name can be re-registered by someone else, and after how long, which is the revival hijack surface. Scoped or org-prefixed namespaces (@scope/pkg, group:artifact) shrink the problem and are worth noting where they exist.
The adjacent surface is typosquatting: names that are different to the registry and the same to a human. It’s been demonstrated against every major registry, and the design question is whether publish-time checks do anything about it.
Maintainer lifecycle — registry
The xz incident is the reference case: every system stayed intact, a new maintainer was added through the normal process, and the trust users had placed in the original author transferred silently to someone with different intentions. A social-engineering campaign is outside what the tool can prevent, but the tool does control how visible the handover is.
Establish how a maintainer is added, whether existing users get any signal when the set changes, whether a newly added maintainer can publish immediately or after a delay, and whether there’s any concept of role. Then whether every change to who-can-publish (add or remove maintainer, org membership change, package transfer, publisher configuration) emits a durable audit record; the absence of that record keeps showing up.
The set can also shift outside what the registry records, because on most registries a maintainer’s identity is control of an email address. Password reset goes to whatever address is on file, and if that address is at a lapsed domain, registering the domain is enough to take the account. Record what account recovery rests on and whether a long-dormant account can ship a release with only a reset link.
Immutability — registry
Once a version is published, the bytes should stay fixed. Check whether a version can be deleted, and if so whether the name+version becomes available again or is tombstoned; what “yank” means (hidden from resolution but still installable by lockfile, or gone); whether there’s a window after publish during which a version can be silently replaced; and whether the answers differ between the CDN, the API, and any mirrors.
Provenance — registry
For most of the history of package registries the link between a tarball on the registry and the repository it claims to come from has been unverifiable. The repository field is a string the publisher typed. Trusted publishing ties the publish credential to a specific CI workflow, and provenance attestations record which commit and workflow produced the artifact. Note whether the registry supports either, whether the client surfaces it, and roughly what fraction of popular packages actually use it, because an opt-in attestation on, say, three percent of packages is a different property from a mandatory one.
The publish credential — registry
The publish token is what attackers exfiltrate from CI logs, phish from maintainers, and find in old commits. Map out: scope (one package, or everything the owner can publish), capability (publish-only, or also add maintainers and change settings), expiry (mandatory, optional, none, and whether an expiry column that exists is actually compared to the current time anywhere), and whether the 2FA-on-publish requirement has an automation-token bypass and how narrow it can be made. Then what happens to a leaked one: whether it has a recognisable prefix and the registry is enrolled with the secret scanners that would auto-revoke it. The impact of a leaked CI variable ranges from the whole account (a session-equivalent key with no expiry) to a single bad release (a short-lived OIDC-exchanged token scoped to one package).
Feature composition — registry
For each grant of authority the registry can confer (package ownership, publish permission, an MFA-verified session, org membership, a trusted-publisher binding), enumerate every code path that produces it, and check whether any path’s precondition is satisfiable by a different path’s output. The high-severity findings that fit no CWE tend to be exactly this: two features that are each fine on their own and whose trust assumptions differ at the seam. A pending-publisher configuration meant for your own repo, applied to someone else’s; an MFA-skip parameter meant for one flow, honoured on another. Both are invisible to a pattern scanner.
Blast radius, detection, and abuse — registry
If a maintainer account publishes a malicious version, how far does it spread before anyone can plausibly notice, and what does the registry give responders to work with? Anomaly detection on publish (new maintainer, dormant package, new country), a way to mark a version as malicious so clients refuse it, an audit log of who published what from where, a way to tell downstream users they’ve installed something since pulled.
The related question is abuse of the registry outside supply-chain attacks: throwaway accounts using it as free blob storage, exfiltration drops, malware staging, dependency-confusion canaries. Push-rate limits, content heuristics on first publish from a new account, and account-age gates are the usual controls; whether any exist is worth recording.
The tool’s own dependencies — both
Both halves of this document apply recursively: the client is software with dependencies, usually from the ecosystem it serves (npm is an npm package, Bundler is a gem, Cargo is built with Cargo), and the registry is an application with a manifest that often resolves against itself. A compromised package in either tree is code execution inside the thing the rest of the ecosystem depends on.
Record how the client’s and registry’s dependencies are handled: vendored, pinned by content hash in a committed lockfile, or resolved at build time from the live registry. pip vendors everything under pip._vendor to break the loop on the client side. On the registry side the sharper question is whether anything in the deploy’s dependency tree runs install-time hooks, because that’s where a dependency becomes code on the box that holds everyone’s publish credentials.
The tool’s own release pipeline — both
The pipeline contains paths into the shipped artifact outside any lockfile, and this is where audits keep finding the same things:
- An install script (
curl | sh,install.ps1, a setup action) that downloads a release binary with no checksum and no version pin - Third-party CI actions pinned by mutable tag rather than commit SHA, in a job holding a publish credential
- A
workflow_dispatchrelease job with no branch guard, so anyone with Actions write can ship from an unreviewed branch - A bootstrap step (a prebuilt toolchain, a stage-0 compiler, a helper binary fetched during the build) downloaded without a hash
- Release artifacts uploaded without a
SHA256SUMSfile or an attestation that would let anyone verify them later pull_request_targetworkflows, or any workflow reachable from an unprivileged contributor, with access to secrets
The installed client’s self-update path is part of the same surface: rustup self update, npm updating npm, brew update running a git pull of the tool’s own source, a curl-pipe installer the docs tell users to re-run. Check how the update is verified before it replaces the running tool (a signature, a checksum fetched from where, TLS alone) and whether that verification matches what the tool requires of the packages it installs.
For a compiler there’s a further layer, since building one usually requires a previous version of itself and somewhere at the bottom is a binary compiled by someone other than the current maintainers. Where that binary comes from and whether it can be independently reproduced is Thompson’s question, and the answers currently in the wild range from an unsigned tarball on a CDN through Go’s reproducible toolchain builds to the full-source bootstrap work.
Output
A project that answers Part II in writing has something close to a published threat model: npm’s threats-and-mitigations page and the OpenSSF’s Principles for Package Repository Security are existing examples. For an audit, four artifacts are worth producing alongside the prose.
A sources-and-sinks inventory: every place untrusted data enters, grouped by who controls it (network attacker, upstream server, package author, PR author, hosting infra, user), every place it can do damage (eval, subprocess, root exec, file write with attacker-influenced path, credentialed request, terminal, deserialisation), the filters between them, and explicit source→[filters]→sink paths. This is what makes the audit reproducible.
A negative-results list: each dimension with zero findings, with the invariant that made it safe and where in the code that invariant is enforced. These are often the only written record of the invariants the code depends on. The invariant and its enforcement point are required; an empty search establishes only that the search came back empty.
An unverified-assumptions list: the places where the audit failed to establish an invariant from the available code, whether the CDN strips a header, whether a deploy-time setting overrides the repo’s default, whether the registry enforces what the client leaves unchecked. Record the assumption, what was searched, and what evidence would resolve it. An unverified assumption stays on this list rather than being promoted to a finding or a negative result.
A scope statement matching the trust boundary, so a future report can be triaged as bug, accepted design, or out of scope against the recorded model.
The findings themselves have a bar: a reportable finding traces an attacker-controlled source to a sink, names the validation between them, and states which trust-boundary assumption makes the path exploitable. A dangerous primitive on its own is a place to look. An untraced suspicion goes on the unverified-assumptions list, and an intended behaviour with security consequences is a design property for the Part II prose rather than a finding.