In November 2018, a maintainer of the popular npm package event-stream handed off publishing rights to a stranger who had offered to help — a stranger who then quietly added a dependency called flatmap-stream containing code that targeted the Copay Bitcoin wallet. The package had roughly 2 million downloads a week and nobody noticed for over two months, because npm's install process runs lifecycle scripts automatically and nothing in the toolchain flagged the change. That single design decision — code executes on install, by default, with no prompt — has shaped a decade of npm supply chain incidents. Not every package manager makes that choice. Go modules, Maven Central, and PyPI all handle installation, namespacing, and trust differently, and those differences directly determine what a compromised or malicious package can actually do once it lands on a developer's machine. This post walks through the concrete design decisions that widen or narrow that attack surface.
Why Do Install Scripts Turn Package Managers Into Remote Code Execution Engines?
Because several major package managers execute arbitrary code the moment a package is installed, not just when it's imported or run. npm's package.json supports preinstall, install, and postinstall hooks that fire automatically during npm install, with no confirmation step. This is precisely the mechanism attackers exploited in the ua-parser-js compromise on October 22, 2021, when a hijacked maintainer account pushed versions 0.7.29, 0.8.0, and 1.0.0 containing a cryptominer and a Windows password-stealer to a package with roughly 7 million weekly downloads. Two weeks later, on November 4, 2021, the same account-takeover pattern hit coa and rc, two packages buried deep in the dependency trees of react-scripts and vue-cli, meaning millions of developers ran npm install and got malware without touching either package directly. Python's setup.py has the equivalent problem: pip install can execute arbitrary Python during the build step, which is how a researcher was able to demonstrate takeover of the abandoned ctx package in May 2022 by simply re-registering its expired maintainer domain. RubyGems has the same exposure — the strong_password gem was hijacked in February 2019 to inject code that exfiltrated Rails secret_key_base values from any app that installed it. In all three ecosystems, the vulnerability isn't a bug in a specific package — it's that the package manager treats "download" and "execute" as the same event.
Why Does Dependency Confusion Exploit Naming, Not Code?
Because most public registries resolve packages by name and version number alone, without verifying which namespace a name is "supposed" to belong to. Security researcher Alex Birsan demonstrated this at scale in February 2021, publishing public packages on npm and PyPI that matched the internal, private package names used by more than 35 companies — including Apple, Microsoft, PayPal, Shopify, Netflix, Tesla, and Uber — and having his code execute inside their build systems because the public registry's higher version number won the resolution race against their internal repositories. He collected over $130,000 in bounties across the affected companies. The root cause is a design default: unscoped package names are global and first-come, and build tooling often checks public registries either before or in place of private ones. npm's scoped packages (@org/package-name) provide a partial mitigation by giving organizations a reserved namespace, but scoping is opt-in — most existing packages were published unscoped long before the feature existed, and PyPI has no equivalent namespace reservation mechanism at all, relying instead on manual name-squatting takedown requests. The attack surface here is entirely a product of how names are resolved, not any flaw in the packages themselves.
Why Don't Lockfiles Stop Supply Chain Attacks?
Because a lockfile only guarantees you get the same code twice — it says nothing about whether that code was safe the first time. package-lock.json, yarn.lock, Pipfile.lock, and Gemfile.lock all pin exact resolved versions (and often hashes) so that a second install reproduces the first. But that protection has real gaps: if the lockfile is generated after a compromised version is already published, the malicious code gets pinned right alongside everything else. If a developer runs npm install <new-package> to add a dependency, npm updates the lockfile to include whatever is live on the registry at that moment, with no independent verification step. And lockfiles only cover what's already in the dependency graph — they don't stop a legitimate, previously-trusted maintainer account from pushing a new malicious version, which is exactly the failure mode in the event-stream, ua-parser-js, coa, and strong_password incidents above; all of those lockfiles would have faithfully "locked" the compromised versions. The xz-utils backdoor discovered by Andres Freund on March 29, 2024 (CVE-2024-3094) illustrates the same category of limitation one layer down the stack: the malicious code was injected into the release tarball, not the public Git repository, so any hash-pinning or lockfile mechanism checking against the tarball would have "correctly" reproduced a backdoored build. Reproducibility and safety are different guarantees, and package manager design frequently conflates them.
Why Has Go's Module System Blocked an Entire Class of Attacks?
Because Go removed both install-time code execution and mutable package history from its design, closing off two attack vectors other ecosystems still carry. Since the Go module system matured around Go 1.13 in 2019, go get and go build do not execute arbitrary scripts during dependency resolution the way npm install or pip install do — there is no postinstall equivalent. Go also introduced a checksum database (sum.golang.org) and a module proxy (proxy.golang.org) that caches every version of every module the first time it's fetched, making that version immutable afterward; a maintainer cannot silently republish or delete a version the way npm and RubyGems maintainers historically could. This doesn't make Go immune — typosquatting still works, and in August 2022 a malicious Rust crate called rustdecimal (typosquatting the legitimate rust_decimal) used a build.rs script, Cargo's equivalent of a postinstall hook, to download and execute malware at compile time, showing that Rust kept the install-time-execution design choice even where Go removed it. The comparison matters: ecosystems that chose immutability and no-execution-by-default measurably narrowed their own attack surface, while ecosystems that kept build scripts for legitimate use cases (compiling native bindings, running codegen) kept the RCE vector open for attackers too.
Why Do Signing and Provenance Requirements Remain the Exception Rather Than the Rule?
Because most registries treat cryptographic verification as optional infrastructure rather than a default gate. Maven Central has required GPG signatures on published artifacts for years, but signature presence isn't the same as signature verification — Maven and Gradle builds don't fail by default when a signature is missing or doesn't match a known key, so the control exists on paper more than in practice. npm introduced provenance attestations via Sigstore in mid-2023, letting a package cryptographically prove it was built from a specific public source repository and CI workflow, but adoption is opt-in and still covers a minority of the registry. PyPI's more consequential move was Trusted Publishing, launched in April 2023, which lets projects publish via short-lived OIDC tokens from CI instead of long-lived API keys that can leak or be phished — directly addressing the maintainer-account-takeover pattern behind several incidents above. Both npm and PyPI also moved toward mandatory two-factor authentication for high-impact maintainers: npm required it for the top 100 packages starting in February 2022, and PyPI announced mandatory 2FA enrollment for all "critical" projects to be completed by the end of 2023. These are meaningful steps, but they were retrofitted years after each ecosystem reached mass adoption, which is why so much of the existing package graph still runs on unverified identity and unsigned artifacts.
How Safeguard Helps
Package manager design choices aren't something any single engineering team can fix upstream — npm isn't going to remove postinstall scripts, and most organizations can't rewrite their dependency tree in Go. What they can do is add the verification layer that the registries themselves don't enforce by default. Safeguard continuously monitors your open-source dependencies across npm, PyPI, RubyGems, Maven, and Cargo for the specific signals this post covers: newly added or modified lifecycle scripts, maintainer account and publishing-key changes, version jumps that don't match the project's commit history, and packages that shadow your internal or private namespaces before a dependency-confusion attempt can resolve in your favor. Rather than trusting a lockfile's reproducibility as a proxy for safety, Safeguard evaluates each dependency's actual behavior and provenance — including whether it carries verifiable build attestations — so a compromised update gets flagged before it reaches a build pipeline, not after a researcher discovers it. For teams that inherited years of unscoped, unsigned, script-executing dependencies, that continuous verification is the practical substitute for the safer defaults their package manager never shipped.