Malicious Package Detection: Respond Like It's an Incident

A vulnerability is a mistake someone might exploit. A malicious package is code written to do harm that already ran in your build.
Malicious package detection is only half the job. Why a compromised dependency is an incident, not a CVE, and a response playbook for the day one lands.
June 8, 2026

Most AppSec programs run one pipeline for dependency risk. A finding comes in, it gets a severity, it gets an SLO, and it waits its turn. That pipeline was built for vulnerabilities. It is the wrong pipeline for a malicious package.

A vulnerability is a mistake someone might exploit. A malicious package is code that was written to do harm and is already inside your build. Malicious package detection is only half the job. The other half is treating what you detect as an incident, not a ticket.

This post covers why the two need different handling, where malicious code actually enters a codebase, and a response playbook you can run the day a compromised package shows up in your inventory.

Malicious packages vs vulnerable packages

The difference is intent, and intent changes every downstream decision.

  • A vulnerable package has a defect. Exploiting it needs an attacker, a reachable code path, and usually an exposed service. Severity, reachability and exposure are the right questions.
  • A malicious package has a payload. It does not wait for an attacker to find it. It runs when it is installed, imported or built, and it does what its author wanted: steal tokens, read environment variables, plant persistence, or spread.

That is why the usual prioritization signals mislead here. CVSS scores a weakness. EPSS estimates exploitation likelihood for a weakness. Reachability asks whether your code calls the vulnerable function. None of them apply to a preinstall script that exfiltrates ~/.npmrc the moment a developer runs npm install. The code never needs to be reachable from the internet. It already ran.

So the question for a malicious package is not "how urgent is this?" It is urgent. The questions are where it landed, whether it executed, and what it could reach.

How malicious code gets into a dependency tree

Knowing the entry points tells you where to look and which controls matter. The recurring patterns:

Typosquatting and lookalike names

A package published under a name one keystroke away from a popular one. AI coding agents make this worse, not better: an agent that hallucinates a plausible package name and installs it is doing the attacker's work for them.

Account takeover of a trusted project

A maintainer's credentials are stolen and a malicious release is pushed under a name everyone already trusts. The 2021 ua-parser-js hijack worked this way. No typo, no suspicious name. A new version of a package you already depend on.

Long-game maintainer compromise

The xz utils backdoor in 2024 (CVE-2024-3094) was planted by a contributor who spent years earning commit rights. It is the extreme case, and it is the reason "we only use well-known packages" is not a control.

Dependency confusion

A public package published with the same name as an internal one, so a misconfigured resolver pulls the public copy. Your first-party packages are part of this attack surface.

Self-propagating worms

Campaigns like the Shai-Hulud npm worm steal publish tokens from infected machines and use them to push malicious versions of the victim's own packages. Each infection becomes a new publisher.

CI and build tooling

Your GitHub Actions are dependencies too. The March 2025 compromise of tj-actions/changed-files (CVE-2025-30066) retargeted a widely used action's tags to code that dumped CI secrets into build logs. Anything pinned by tag instead of by commit SHA followed the tag.

The common thread: the malicious code arrives through a normal-looking change. A version bump. A new dependency. A workflow edit. Nothing in the diff says "attack".

Why SLO-driven triage fails for compromise

Vulnerability management is designed to be patient. You have hundreds or thousands of findings, a limited number of engineers, and an SLO that says criticals get fixed in a set number of days. That patience is correct for most CVEs.

It is a failure mode for malicious packages, for three reasons.

  1. The damage happens at install time. Removing the package on day 14 does not undo the token it stole on day 1. The window that matters is between the malicious version being published and the first time anything in your estate installed it.
  2. Patching is not remediation. Upgrading past the bad version stops future installs. It does nothing about credentials already taken, persistence already planted, or packages you publish that were already poisoned.
  3. Exceptions make no sense. A team can reasonably accept the risk of an unreachable CVE on an internal service. Nobody can accept the risk of running an attacker's code. A risk-acceptance workflow on this finding type is a way to lose an incident in a queue.

If your tooling files a compromised package alongside 4,000 medium-severity CVEs and sorts by CVSS, it is hiding the most dangerous thing in the list.

A response playbook for a malicious package in your inventory

Run this the day a compromised package matches your dependency inventory. It assumes nothing about whether the payload fired. Prove that, do not assume it.

1. Establish every place it landed

  • List every repository, module and manifest that resolves the malicious version, direct or transitive. The unit is the place, not the package: one package in four modules is four investigations.
  • For transitive hits, find which direct dependency pulled it in. That is what you will change.
  • Include build systems and CI runners, as well as source. Check container images and deployed services that were built during the exposure window.

2. Work out whether it executed

  • When was the malicious version first resolved into a lockfile? That date bounds the exposure window.
  • Which environments installed it in that window: developer laptops, CI jobs, build agents, production images?
  • Did the payload need an install script, an import, or a build step to run? Advisory write-ups usually say. That tells you which of those environments actually executed it.

3. Contain

  • Remove or pin past the malicious version in every affected manifest and lockfile.
  • Block the version at your registry proxy or artifact repository so a stale lockfile cannot reintroduce it.
  • If the package is one you publish, check whether a stolen token was used to publish from your account.

4. Rotate what it could reach

Treat every secret available to an environment that ran the payload as exposed. On a CI runner that usually means the repository's secrets, cloud credentials, registry publish tokens and any deploy keys. On a developer laptop it means local config files such as ~/.npmrc, cloud CLI credentials, SSH keys and Git tokens.

5. Hunt for what it left behind

  • Search repositories for planted files: web shells, environment dumpers, persistence scripts, new or modified workflow files. Search the full Git history as well as the current tip. Attackers delete their tracks, and a file removed in a later commit is still in history.
  • Review recent pushes to default branches and workflow changes made outside pull requests.
  • Check cloud audit logs for use of any credential that was exposed.

6. Close the entry point

Once the incident is contained, change the conditions that let it in. The next section covers the controls that do the most work.

Controls that shrink the window

Most malicious versions are found and pulled within days of publication. That makes time the cheapest control you have.

  • Minimum release age. Configure package managers and CI to refuse versions published in the last few days. npm, pnpm, Yarn, uv and others support a minimum age or cooldown setting in recent versions. Match the same threshold in your PR checks so the two agree.
  • Lockfiles, always. A committed lockfile turns "whatever the registry serves today" into a reviewable change. Use npm ci, not npm install, in CI.
  • Restrict install scripts where you can. npm ci --ignore-scripts removes the most common execution path. Some packages break without scripts, so treat it as an allowlist exercise, not a blanket switch.
  • Pin third-party actions by commit SHA. uses: actions/checkout@<40-char-sha>, not @v4. A tag can be moved. A SHA cannot.
  • Least-privilege CI tokens. Set permissions: explicitly in workflows so a compromised step cannot write to the repository or publish packages it has no reason to touch.
  • Block known-malicious files at the pull request. Some attack tooling has no CVE and never will. A file that dumps environment variables to a remote host should fail review automatically.

The SLSA framework and the OpenSSF publish guidance on build provenance and package integrity that complements these controls.

How Heeler handles compromise differently

Heeler keeps compromised packages out of the vulnerability queue on purpose.

  • A separate list with no exception mechanism. Compromised Dependencies lists packages that match Heeler's malicious-package intelligence, one row per module, package and version, with the advisory behind it and when it was introduced. There is no risk or SLO override on this list. You cannot mark malicious code as accepted.
  • Indicators of compromise are always urgent. A planted-malware finding does not get deferred because the code looks internal or authenticated. Exposure scoring applies to weaknesses, not to attacker tooling that is already present.
  • Full-history forensic checks. Forensic source checks can run across every branch and the whole commit history, so a planted file that was later removed still surfaces.
  • Prevention at the pull request. IOC guardrails block a pull request that adds a known-malicious file, such as a web shell, a credential dumper or a worm's implant script. Dependency guardrails can enforce a minimum package age, and the Heeler CLI can check for typosquatted, malicious and compromised packages before anything is committed.

The docs cover the details: Compromised Dependencies, IOC Guardrails and Package-Manager Cooldown. For how this fits a wider program, see software supply chain security.

What to do this week

  • Check whether your tooling separates malicious packages from vulnerable ones. If they share a list, sorted by CVSS, fix that first.
  • Write the playbook above into your incident runbook, with owners for rotation and for CI.
  • Turn on a minimum release age in your package managers and CI, and match it in your PR checks.
  • Pin third-party GitHub Actions by commit SHA and set explicit workflow permissions:.
  • Run one historical search of your repositories for known attack tooling. You want to know now, not during an incident.

FAQ

What is the difference between a malicious package and a vulnerable package?

A vulnerable package contains a defect that someone might exploit, while a malicious package contains code written to cause harm that runs when it is installed, imported or built. The first is triaged by severity and exposure. The second is an incident.

Does removing a malicious package fix the problem?

Removing it stops future installs, and nothing more. Anything the payload already did, such as stealing tokens or planting persistence, remains until you rotate the exposed credentials and hunt for what was left behind.

How do I detect malicious packages in my dependencies?

Match your full dependency inventory, including transitive dependencies and CI actions, against malicious-package intelligence such as OSV's malicious package entries and GitHub's malware advisories. Also scan source and history for known attack files, because some attack tooling is committed directly rather than installed.

Should compromised packages have an SLO?

No. An SLO implies acceptable delay, and there is no acceptable delay for code that already ran in your environment. Handle compromise through incident response with immediate ownership.

What is the single most effective prevention control?

A minimum release age at install time is the cheapest high-value control, because most malicious versions are detected and removed within days of publication. Combine it with lockfiles and SHA-pinned CI actions.

See it on your own inventory

Heeler matches every dependency, transitive package and GitHub Action you use against malicious-package intelligence, keeps compromise out of the vulnerability queue, and blocks attack tooling at the pull request. Get a demo.

What’s new on Heeler
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Related resources

See All Resources