How to avoid malicious code comes down to three controls: decide deliberately what enters your dependency tree, neutralize code that runs automatically at install time, and limit what a compromised build step can reach. Most teams over-invest in scanning for known CVEs and under-invest in these three, even though modern package attacks are usually zero-day by definition: the malicious version is brand new, has no CVE yet, and is often live for hours before anyone notices.
This is a checklist you can actually implement, ordered by effort-to-payoff.
Know what you are defending against
Malicious code reaches teams through a handful of repeatable channels:
- Typosquats and lookalikes:
requetsinstead ofrequests, or a package copying a popular name into a different ecosystem. - Dependency confusion: an attacker publishes a public package with the same name as your internal one at a higher version, and a misconfigured resolver prefers it.
- Hijacked maintainer accounts: a legitimate, popular package ships a poisoned release. The
ua-parser-jsandevent-streamincidents both worked this way, and the xz-utils backdoor showed the patient version: a contributor spends years earning commit access first. - Install-time scripts: the payload is not in the library code at all, but in a
postinstallhook that runs the moment your CI doesnpm install. - Compromised build infrastructure: malicious steps injected into CI workflows, often to exfiltrate secrets rather than to modify your product.
Note what is absent: exotic malware. The attacker's code is usually a short script that reads environment variables and POSTs them somewhere. It succeeds because installation is trusted by default.
Control the front door: what gets installed
Pin everything with lockfiles, and make CI enforce them. A lockfile only protects you if installs fail when it is out of sync:
npm ci # not: npm install
pip install --require-hashes -r requirements.txt
npm ci installs exactly what the lockfile says or errors out. Hash-pinned requirements do the same for Python: even if a registry serves a swapped artifact for the same version string, the hash check fails.
Add a cooling-off period for new versions. Malicious releases are typically detected and pulled within hours or days. Configuring your dependency-update tooling to wait before proposing an upgrade cuts your exposure dramatically. In Renovate, this is one line:
{ "minimumReleaseAge": "7 days" }
Kill dependency confusion structurally. Use scoped packages (@yourorg/utils) so public lookups cannot match, or configure the resolver so internal names only ever resolve from the internal registry. Test it: publish a harmless canary package with an internal name to the public registry and confirm your builds never fetch it.
Review what a new dependency drags in. Before adopting a package, look at its transitive tree, maintenance signals, and install scripts. Automate the boring part: a software composition analysis pipeline can diff every pull request's dependency changes and flag new packages, new maintainers, and new install hooks — the review then takes minutes instead of being skipped.
Neutralize install-time execution
This is the highest-value single control for how to prevent malicious code from running in the first place, because it breaks the most common payload delivery outright.
For npm:
npm ci --ignore-scripts
Some packages genuinely need build scripts (native modules, mostly). The workable pattern is default-deny with an allowlist: pnpm supports this directly via the onlyBuiltDependencies setting, and Bun ships with install scripts disabled by default. For everything else, run the install step in a container with no secrets mounted, so a script that does fire has nothing worth stealing.
For Python, remember that pip install of a source distribution executes setup.py. Preferring wheels (--only-binary :all: where feasible) removes that execution path.
Limit the blast radius in CI
Assume something malicious eventually runs in a build. What can it reach?
- Scope tokens tightly. The default
GITHUB_TOKENshould be read-only (permissions: contents: readat the workflow top). Publishing credentials belong only in the one job that publishes, injected at that step, not exported globally. - Pin third-party CI actions by commit SHA, not by tag. Tags are mutable; a hijacked action re-tagged as
v4flows straight into every consumer. - Egress-filter build agents where you can. Exfiltration needs an outbound connection. Even a coarse allowlist of registries and internal hosts turns a successful compromise into a dud.
- Separate build from deploy. The job that compiles untrusted third-party code should not hold cloud credentials at all.
Defend your own contribution surface
How can you avoid malicious code entering through your own accounts and repos?
- Enforce 2FA (ideally phishing-resistant WebAuthn) for the package registry and SCM organizations. Most maintainer-hijack incidents began with a phished password.
- Require signed commits or at minimum branch protection with review on release branches.
- Use trusted publishing (OIDC-based) for PyPI and npm instead of long-lived registry tokens sitting in CI secrets.
- Watch for unfamiliar automation: a new webhook, deploy key, or workflow file appearing in a repo is a common persistence step.
Detection: for the ones that get through
Prevention will not be perfect, so instrument for the behaviors malicious packages exhibit:
- Diff dependency updates, don't just accept them. The
event-streampayload sat in a new sub-dependency added by a minor release; a human diffing the lockfile change had a real chance of noticing. - Monitor advisories continuously, not just at build time. A package that was clean on merge day can be flagged a week later. Continuous monitoring against advisory feeds catches that window; this is standard in SCA platforms, Safeguard included.
- Alert on outbound connections from build steps to hosts outside your known registry set.
- Keep SBOMs per build, so when an incident like a poisoned release is announced you can answer "did we ever ship this version" in minutes rather than by archaeology.
A one-page checklist
- Lockfiles enforced in CI (
npm ci,--require-hashes) - Minimum release age (around 7 days) on automated updates
- Internal package names unresolvable from public registries
- Install scripts disabled by default, allowlisted per package
- CI token permissions read-only by default; publish creds job-scoped
- Third-party actions pinned to SHAs
- 2FA/WebAuthn on registry and SCM accounts; trusted publishing over static tokens
- Dependency-diff review on every PR that touches a manifest or lockfile
- Continuous advisory monitoring plus per-build SBOMs
- Egress restrictions on build agents
If you want the reasoning behind each control in course form, the supply chain track in our academy walks through the real incidents these rules were distilled from.
FAQ
How can you avoid malicious code without slowing developers down?
Automate the checks into CI rather than adding approval meetings: lockfile enforcement, install-script policy, and dependency-diff bots run in seconds. The only human step left is reviewing flagged changes, which is rare.
Does antivirus software catch malicious packages?
Rarely. Package payloads are usually short scripts doing "legitimate" things (reading env vars, making HTTP requests), not binaries with known signatures. Ecosystem-aware tooling and behavioral controls are the effective layer.
Are known-CVE scanners useless against this?
No, but they solve a different problem. CVE scanning catches known-vulnerable versions; malicious-package defense is about brand-new hostile releases. You need both, and mature SCA tools now flag malicious packages as a category distinct from vulnerabilities.
What is the single highest-impact control?
Disabling install scripts by default. It converts the most common delivery mechanism (postinstall exfiltration) into a no-op, costs almost nothing, and the exceptions list stays short in practice.