Best Practices

Malicious Package Quarantine Procedures

How to quarantine a malicious package across your registries, caches, and running systems without breaking every developer's workflow.

Shadab Khan
Senior Security Engineer
6 min read

Quarantine sounds simple. You have a malicious package, you block it, you move on. Anyone who has actually done this under time pressure at an organization with more than a hundred engineers knows it is not simple at all. A half-quarantined package is worse than a non-quarantined one because it creates a false sense of safety while the malware still spreads through the thousand pathways you missed.

The Quarantine Surface

Before you can quarantine, you have to know where the package can enter your environment. I draw this as a funnel for every engagement. At the top is the public registry (npm, PyPI, RubyGems, Maven Central). Below that is your pull-through cache (Artifactory, Nexus, Verdaccio). Below that is your CI artifact cache. Below that is the developer's local cache. At the bottom are the running systems that already installed the package.

Every level of the funnel needs a separate quarantine action. If you only block at the cache, developers with warm local caches will keep running the malicious code. If you only block at the cache and CI, already-built artifacts will keep deploying. Quarantine is a systematic sweep, not a single lever.

Step One: Block Ingress at the Cache

The first move is your pull-through cache, because that is the choke point. For Artifactory, you add a blocked pattern to the remote repository:

curl -u admin:$ART_TOKEN -XPOST \
  "https://artifactory.internal/artifactory/api/repositories/npm-remote" \
  -H 'Content-Type: application/json' \
  -d '{"blackedOut": false, "excludesPattern": "evil-pkg/-/evil-pkg-*.tgz,**/evil-pkg/**"}'

For Nexus, the equivalent is a routing rule that blocks the path pattern. For Verdaccio, add the package to the uplinks denylist and set access: $nobody in the package config. Verify the block by attempting an install from a clean environment — if it succeeds, your block is wrong.

While you are at the cache, also purge the cached artifact so that anyone with a warm proxy does not get served the malicious version:

curl -u admin:$ART_TOKEN -XDELETE \
  "https://artifactory.internal/artifactory/npm-remote-cache/evil-pkg/-/evil-pkg-4.2.1.tgz"

Step Two: Block at the Lockfile Resolver

The cache block stops new pulls but does not stop an existing lockfile from resolving the bad version. You need a second layer that prevents the resolver itself from accepting the malicious version. npm supports this through overrides in the root package.json:

{
  "overrides": {
    "evil-pkg": "npm:safe-shim@1.0.0"
  }
}

For Python, a constraints.txt file with evil-pkg==0.0.0+quarantined used in pip install -c will fail loudly if anything tries to resolve it. For Go, a replace directive pointing at a local empty module serves the same purpose. Roll these out through a central config repository that is pulled at CI start, not left to individual projects.

Step Three: Purge Developer Caches

This is the step most teams skip, and it is the one that causes most post-quarantine incidents. Every developer with the package in ~/.npm, ~/.cache/pip, ~/.m2, or %LOCALAPPDATA%\pnpm-cache is still at risk. You cannot fix this with a single server-side change — you have to push a purge.

I push a signed shell script through the endpoint management system that does the purge plus a verification check:

#!/usr/bin/env bash
set -euo pipefail
paths=(
  "$HOME/.npm/_cacache/content-v2/sha512/*evil-pkg*"
  "$HOME/.pnpm-store/**/evil-pkg*"
  "$HOME/.cache/pip/wheels/*evil-pkg*"
)
for p in "${paths[@]}"; do
  find $p -type f -delete 2>/dev/null || true
done
echo "purge_complete=$(hostname)=$(date -u +%FT%T)" | \
  curl -s -XPOST -d @- https://ir.internal/api/purge-report

The reporting callback matters. You need a list of which machines have been purged and which have not, because the unreported ones are the ones that will bite you next Monday when that developer comes back from vacation.

Step Four: Quarantine Built Artifacts

Any artifact your CI produced that included the malicious package is tainted. Tag each one in the registry with a quarantine label so it cannot be promoted:

for digest in $(aws ecr describe-images --repository-name myapp \
  --query 'imageDetails[?imagePushedAt>=`2024-06-15`].imageDigest' --output text); do
  aws ecr put-image --repository-name myapp --image-digest "$digest" \
    --image-tag "quarantined-IR-2024-0142" \
    --image-manifest "$(aws ecr batch-get-image --repository-name myapp \
      --image-ids imageDigest=$digest --query 'images[0].imageManifest' --output text)"
done

For Helm charts, npm packages, or Java artifacts, the equivalent is marking them with a custom property or moving them to a quarantine repository that no deploy pipeline pulls from. Update your policy gates to refuse any artifact with the quarantine label, so an accidental retry of a frozen pipeline does not push a tainted image to prod.

Step Five: Handle Running Systems

Running systems that already have the malicious package loaded into memory are the hardest part of quarantine. You cannot un-run code. What you can do is identify every system running an affected version, force a restart onto a clean image, and verify the restart.

Identification queries vary by platform. For Kubernetes:

kubectl get pods --all-namespaces -o json | \
  jq -r '.items[] | select(.spec.containers[].image | contains("myapp:")) |
    "\(.metadata.namespace)/\(.metadata.name) \(.spec.containers[].image)"' | \
  sort -u

Compare each running image digest against your quarantine list. For anything that matches, trigger a rolling update to a clean image. For systems that cannot be restarted cleanly (stateful workloads, appliances), document them and treat them as compromised assets until they can be rebuilt.

Step Six: Communicate and Document

Quarantine is not done until the organization knows it happened. Send a single notification to engineering that covers: what was quarantined, what actions developers need to take (usually a rm -rf node_modules && npm install after pulling the latest root config), what systems are still being remediated, and where to report problems. Link the notification to a tracking doc that updates as the remediation progresses.

Keep the quarantine in place longer than feels comfortable. A common pattern is to lift the quarantine as soon as a clean version is available, only to have the clean version turn out to also be compromised because the attacker published two bad versions. Seventy-two hours minimum with a formal review before lifting has served me well.

How Safeguard Helps

Safeguard operationalizes package quarantine as a first-class workflow rather than a manual scramble across six systems. When a package is flagged, Safeguard can automatically push block rules to your connected registries and caches, tag affected built artifacts, and enumerate the running assets that still carry the compromised version. The platform keeps a signed record of every quarantine action for your audit trail, and when the time comes to lift the quarantine, it walks you through the verification steps so nothing slips through. For the developer notification and purge-reporting steps, Safeguard integrates with Slack and endpoint management so your IR team spends minutes on coordination instead of hours.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.