How to Audit and Rescue Abandoned npm Dependencies in Your Project 2025

Prerequisites and What You'll Need

Tools required: npm, npx, and Node.js version compatibility

Before you start, make sure your environment meets these minimums. The scripts below use Array.prototype.flatMap, top-level await (via --input-type=module), and the fetch API, so you need at least Node.js 18.

  • [ ] Node.js ≥ 18.0.0 — run node --version to confirm
  • [ ] npm ≥ 9.0.0 — run npm --version to confirm; npm 9 ships the npm view JSON output format used in Step 1
  • [ ] jq ≥ 1.6 installed and on your PATHjq --version
  • [ ] GitHub CLI (gh) ≥ 2.40 for automated issue creation in Step 6 — gh --version
  • [ ] A project with at least 20 direct dependencies — below that count the audit overhead isn't worth the ceremony; just review manually
  • [ ] Read access to package-lock.json — this must be committed; if your project only has yarn.lock or pnpm-lock.yaml, adapt the jq paths accordingly

Note: The tools npm-check, libyear, depcheck, and the socket CLI are all installed ephemerally via npx in this guide. No global installs required.

Estimated time: 45–90 minutes for the initial audit of a medium-sized project (50–200 dependencies). The CI setup in Step 6 is a one-time 15-minute investment after that.


Step 1 — Identify Potentially Dead Dependencies

You can't fix what you haven't measured. The npm registry stores a time object for every package that records every publish event, including modified — the last time the registry record changed. Querying this at scale is the fastest way to surface candidates for deeper review.

Using npx npm-check to surface stale packages

Start with a quick pass:

npx npm-check --skip-unused

This gives you an interactive list of packages with available updates. It won't tell you about abandonment directly, but anything showing as "not updated in a long time" with no newer version is an immediate candidate.

Querying npm registry metadata for last publish date

The shell script below reads every package name from your package-lock.json, queries npm view, and outputs a sorted list of days since last publish.

#!/usr/bin/env bash
# check-staleness.sh — pipe output to a file or grep for HIGH risk
set -euo pipefail

PACKAGE_LOCK="./package-lock.json"
THRESHOLD_DAYS=365

echo "package,days_since_publish,last_published"

# Extract all direct dependency names from package-lock.json
pkg_names=$(jq -r '.packages | to_entries[] | select(.key | startswith("node_modules/") and (contains("/node_modules/") | not)) | .key | ltrimstr("node_modules/")' "$PACKAGE_LOCK" | sort -u)

while IFS= read -r pkg; do
  # npm view returns empty for scoped private packages — skip gracefully
  modified=$(npm view "$pkg" time.modified --json 2>/dev/null || echo "null")
  if [ "$modified" = "null" ] || [ -z "$modified" ]; then
    echo "$pkg,UNKNOWN,UNKNOWN"
    continue
  fi
  # Strip quotes from JSON string
  modified_clean=$(echo "$modified" | tr -d '"')
  # Compute days since last publish (macOS uses -j -f; Linux uses -d)
  if date --version &>/dev/null 2>&1; then
    # GNU date (Linux)
    epoch_then=$(date -d "$modified_clean" +%s 2>/dev/null || echo 0)
  else
    # BSD date (macOS)
    epoch_then=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${modified_clean%%.*}" +%s 2>/dev/null || echo 0)
  fi
  epoch_now=$(date +%s)
  days=$(( (epoch_now - epoch_then) / 86400 ))
  echo "$pkg,$days,$modified_clean"
done <<< "$pkg_names" | sort -t',' -k2 -rn

Run it with bash check-staleness.sh | tee staleness.csv. Anything above 730 days (two years) with no changelog activity is a red flag.

Cross-referencing GitHub repo archived status and last commit date

The npm metadata alone misses two critical failure modes described by Andrew Nesbitt: the ghost maintainer (repo not archived, issues unanswered, maintainer quietly moved on) and the corporate orphan (company logo still in README, entire team has left, nobody knows it's theirs). Both look identical from npm metadata — time.modified can be years old with no archived flag. Always cross-check the GitHub repo for an archived: true field via the GitHub API before deciding a package is merely slow.


Step 2 — Score Abandonment Risk with a Triage Matrix

A raw "days since publish" number is necessary but not sufficient. A package published 900 days ago that does exactly one thing perfectly is very different from an authentication middleware that hasn't merged a security PR in 600 days.

Key signals: last commit, open issue count, unanswered PRs, archived flag

For each candidate from Step 1, collect these five signals:

| Package | Last Publish | Last Commit | Issues Open | Archived | Risk Level | |---|---|---|---|---|---| | left-pad | 2017-03-23 | 2017-03-23 | 12 | No | High | | ms | 2023-11-28 | 2023-11-28 | 8 | No | Low | | chalk | 2023-08-21 | 2024-01-15 | 31 | No | Low | | node-uuid | 2015-11-17 | 2015-11-17 | 47 | Yes | High | | colors | 2022-01-09 | 2022-01-09 | 89 | No | Medium | | request | 2020-02-14 | 2020-03-03 | 133 | Yes | High |

Distinguishing 'done' packages from truly dead ones

The most important distinction in your triage: a package can be done rather than dead. A 200-line utility that parses a stable file format, has no open security issues, and hasn't needed a commit in three years is probably fine. Contrast that with a package sitting in your authentication path or your build pipeline — those need active maintenance because their threat surface changes even when their API doesn't.

Building a risk score table for your dependency list

Assign High risk when: archived flag is set, OR last publish > 2 years AND open security issues exist, OR last commit > 3 years AND it touches crypto/auth/network. Assign Medium when: last publish is 1–2 years, no security issues, not infrastructure-adjacent. Everything else is Low.

Note: A funder's logo in the README is a false-healthy signal. The funding cliff pattern — project ran on a grant that expired — looks identical to a healthy sponsored project from the outside. Check the grant end date if the README links to a foundation.


Step 3 — Verify Transitive (Indirect) Dependencies Too

Your direct dependencies are only part of the story. The most depended-on dead packages in the ecosystem are almost always transitive — packages your dependencies depend on that you've never explicitly installed. Skipping this step leaves your biggest risk invisible.

Why your direct dependencies may depend on dead packages

Your package.json might have 30 direct dependencies, but package-lock.json often contains 400–1000 resolved packages. Any one of those transitive packages could be abandoned, and if a vulnerability surfaces in it, you own the upgrade path.

Using npm ls --all and depcheck to map the full tree

# Show full flattened tree
npm ls --all --json 2>/dev/null | jq '[.. | objects | select(.version) | {name: .name?, version: .version?}] | unique_by(.name)' > full-tree.json

# depcheck finds packages in lock file not in package.json (unused direct deps)
npx depcheck --json > depcheck-report.json

Automating transitive checks with a Node.js script

The following Node.js script reads package-lock.json directly, extracts every unique package name in the entire tree, queries the npm registry for each, and writes a CSV.

// transitive-audit.mjs
// Run: node transitive-audit.mjs > transitive-staleness.csv
import { readFileSync, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';

const lockfile = JSON.parse(readFileSync('./package-lock.json', 'utf8'));
const out = createWriteStream('./transitive-staleness.csv');

// Extract all package names from the flat packages map (npm v7+ lockfile format)
const allPackages = Object.keys(lockfile.packages || {})
  .filter(k => k.startsWith('node_modules/'))
  .map(k => {
    // Handle scoped packages: node_modules/@scope/name -> @scope/name
    const stripped = k.replace(/^node_modules\//, '');
    // Only top-level of each unique package (ignore nested dupes)
    return stripped;
  })
  .filter(name => !name.includes('/node_modules/')) // skip nested duplicates
  .filter((v, i, a) => a.indexOf(v) === i); // unique

out.write('name,version,last_modified,days_since_publish\n');

const REGISTRY = 'https://registry.npmjs.org';
const MS_PER_DAY = 86400_000;

async function fetchMeta(pkg) {
  try {
    const encodedPkg = pkg.startsWith('@')
      ? `@${encodeURIComponent(pkg.slice(1))}`
      : pkg;
    const res = await fetch(`${REGISTRY}/${encodedPkg}`, {
      headers: { Accept: 'application/vnd.npm.install-v1+json' }
    });
    if (!res.ok) return null;
    const data = await res.json();
    const modified = data.time?.modified ?? null;
    const version = data['dist-tags']?.latest ?? 'unknown';
    if (!modified) return { pkg, version, modified: 'unknown', days: 'unknown' };
    const days = Math.floor((Date.now() - new Date(modified).getTime()) / MS_PER_DAY);
    return { pkg, version, modified, days };
  } catch {
    return { pkg, version: 'error', modified: 'error', days: 'error' };
  }
}

// Batch requests to avoid hammering the registry
const CONCURRENCY = 10;
for (let i = 0; i < allPackages.length; i += CONCURRENCY) {
  const batch = allPackages.slice(i, i + CONCURRENCY);
  const results = await Promise.all(batch.map(fetchMeta));
  for (const r of results) {
    if (r) out.write(`"${r.pkg}","${r.version}","${r.modified}",${r.days}\n`);
  }
  process.stderr.write(`Progress: ${Math.min(i + CONCURRENCY, allPackages.length)}/${allPackages.length}\r`);
}

out.end();
process.stderr.write('\nDone. Output: transitive-staleness.csv\n');

Run with node transitive-audit.mjs. On a project with 400 transitive packages, this takes about 60–90 seconds at the registry's default rate limits.


Step 4 — Find Active Forks or Maintained Alternatives

Once you've identified a dead package you actually depend on, your next question is: who picked this up? The GitHub fork network is your first stop, followed by the npm ecosystem search tools.

Using the GitHub API to list forks sorted by recent activity

# Replace OWNER/REPO with the abandoned package's GitHub coordinates
# Sort by most recently pushed to find the most actively maintained fork
curl -s \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  "https://api.github.com/repos/OWNER/REPO/forks?sort=newest&per_page=30" \
  | jq -r '[
      .[] | {
        full_name: .full_name,
        pushed_at: .pushed_at,
        stars: .stargazers_count,
        open_issues: .open_issues_count,
        days_since_push: ((now - (.pushed_at | fromdateiso8601)) / 86400 | floor)
      }
    ] | sort_by(.days_since_push) | .[:10]
    | (.[0] | keys_unsorted) as $keys
    | $keys, (.[] | [.[$keys[]]] | @csv)'

This outputs a sorted table of the ten most recently active forks with their star count and open issue count — the fastest way to spot a community fork that has genuine traction.

Checking ecosyste.ms and Libraries.io for successor packages

For packages that have a formal successor, check ecosyste.ms — it aggregates repository metadata including dependent counts and last commit across registries. Search the npm registry directly for [original-name] maintained fork or check the original package's package.json deprecated field: npm view [pkg] deprecated.

Evaluating community forks vs. clean rewrites

A fork with 200 stars and 6-month-old commits beats a clean rewrite with 40 stars and no published npm package. Check three things: does it have npm releases (not just GitHub tags), does it have a changelog, and does its test suite pass in CI? The succession deadlock failure mode is real — sometimes the original maintainer refuses to transfer the npm package name, so the best fork has a different npm name entirely and low discoverability despite being the de facto standard.

Note: socket.dev operates a CLI tool (npx socket) that flags packages with abandonment signals, supply-chain issues, and install scripts from packages with low reputation. Run npx socket report create . for a combined security + abandonment report.


Step 5 — Replace, Vendor, or Fork the Dead Package

You've identified a dead package and found a candidate replacement. Now pick the right migration strategy based on how widespread the import is, whether the API is compatible, and whether a published alternative exists.

Option A: Swap to a maintained alternative (with npm alias trick)

The npm alias syntax lets you install a different package under the dead package's name, making the swap transparent to all import statements in your codebase.

// package.json
{
  "dependencies": {
    "request": "npm:got@^13.0.1",
    "node-uuid": "npm:uuid@^9.0.0",
    "colors": "npm:@colors/colors@^1.6.0"
  }
}

After running npm install, any require('request') or import ... from 'request' in your code resolves to got@13 without a single import change. This is the lowest-friction option when APIs are compatible or you're wrapping the dependency behind an adapter layer anyway.

Option B: Vendor the code directly into your repo

For small, stable packages with no ongoing security surface (a pure utility under 500 lines), vendoring is often the right call:

# Example: vendor a small utility
mkdir -p src/vendor/is-plain-obj
cp node_modules/is-plain-obj/index.js src/vendor/is-plain-obj/index.js
cp node_modules/is-plain-obj/license src/vendor/is-plain-obj/license
# Update import in your code:
# import isPlainObj from 'is-plain-obj';
# becomes:
# import isPlainObj from './vendor/is-plain-obj/index.js';

Add a VENDORED.md documenting the source version and license. This eliminates the supply-chain risk entirely for that dependency.

Option C: Publish a community fork to npm under a scoped package

# After forking and making your changes:
cd my-fork-of-dead-pkg
# Update package.json name field:
npm pkg set name="@yourorg/dead-pkg-maintained"
npm pkg set version="2.1.0"
# Publish with provenance (requires npm 9.5+ and GitHub Actions OIDC or local token)
npm publish --provenance --access public

Then in your project:

{
  "dependencies": {
    "dead-pkg": "npm:@yourorg/dead-pkg-maintained@^2.1.0"
  }
}

Publishing with --provenance generates a signed SLSA attestation linking the npm package to the exact Git commit — critical for a security-conscious community fork.


Step 6 — Automate Ongoing Abandonment Monitoring in CI

The one-time audit gets you clean. A weekly CI check keeps you clean. The hired away failure mode — a maintainer joins Apple or another employer with restrictive IP policies and goes silent — can happen to any package at any time. You want to catch it in weeks, not after a vulnerability surfaces.

Adding a weekly GitHub Actions workflow

# .github/workflows/dependency-staleness.yml
name: Dependency Staleness Audit

on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday at 09:00 UTC
  workflow_dispatch:      # Allow manual trigger

permissions:
  contents: read
  issues: write

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci --ignore-scripts

      - name: Run staleness audit
        id: audit
        run: |
          STALE_PKGS=""
          THRESHOLD_DAYS=365

          pkg_names=$(jq -r '
            .packages
            | to_entries[]
            | select(.key | startswith("node_modules/") and (contains("/node_modules/") | not))
            | .key
            | ltrimstr("node_modules/")
          ' package-lock.json | sort -u)

          while IFS= read -r pkg; do
            modified=$(npm view "$pkg" time.modified --json 2>/dev/null || echo null)
            if [ "$modified" = "null" ] || [ -z "$modified" ]; then
              continue
            fi
            modified_clean=$(echo "$modified" | tr -d '"')
            epoch_then=$(date -d "$modified_clean" +%s 2>/dev/null || echo 0)
            epoch_now=$(date +%s)
            days=$(( (epoch_now - epoch_then) / 86400 ))
            if [ "$days" -gt "$THRESHOLD_DAYS" ]; then
              STALE_PKGS="${STALE_PKGS}\n- \`${pkg}\` — last published **${days} days ago** (${modified_clean})"
            fi
          done <<< "$pkg_names"

          echo "stale_pkgs<<EOF" >> $GITHUB_OUTPUT
          echo -e "$STALE_PKGS" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

          if [ -n "$STALE_PKGS" ]; then
            echo "has_stale=true" >> $GITHUB_OUTPUT
          else
            echo "has_stale=false" >> $GITHUB_OUTPUT
          fi

      - name: Open issue if stale packages found
        if: steps.audit.outputs.has_stale == 'true'
        uses: actions/github-script@v7
        with:
          script: |
            const staleList = `${{ steps.audit.outputs.stale_pkgs }}`;
            const title = `[Automated] Stale dependencies found — ${new Date().toISOString().slice(0,10)}`;
            const body = [
              '## Dependency Staleness Report',
              '',
              'The following direct dependencies have not been published to npm in over **365 days**.',
              'Review each package for maintenance status and consider replacing or vendoring.',
              '',
              staleList,
              '',
              '**Next steps:** Run `bash check-staleness.sh` locally and follow the audit guide.',
            ].join('\n');

            // Check for existing open stale issue to avoid duplicates
            const existing = await github.rest.issues.listForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'open',
              labels: 'dependencies,staleness-audit'
            });

            if (existing.data.length === 0) {
              await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title,
                body,
                labels: ['dependencies', 'staleness-audit']
              });
            }

Setting thresholds: when to alert vs. when to block a PR

Use 365 days as the alert threshold (opens an issue). Use 730 days + an open CVE as the block threshold (add a separate job that fails the PR check). Don't block PRs on staleness alone — you'll train your team to ignore the check.


Common Issues & Fixes

Error: npm view returns 404 for scoped private packages

Cause: npm view hits the public registry by default; private scoped packages (e.g., @yourcompany/internal-lib) resolve to your private registry instead.

Fix: Set the registry per-scope in your audit script, or skip private packages entirely since you control them:

# Skip packages matching your org scope in the staleness check
pkg_names=$(jq -r '...' package-lock.json | grep -v '^@yourorg/')

# Or pass the registry explicitly:
npm view "@yourorg/pkg" time.modified --registry https://npm.your-registry.com --json

If you need to audit private packages too, export NPM_TOKEN and add //npm.your-registry.com/:_authToken=${NPM_TOKEN} to a .npmrc in the CI environment.

Error: Package has a recent npm publish but GitHub repo is deleted or private

Cause: This is the corporate orphan with an active registry entry — the company published a patch after the repo was moved or deleted, or npm package ownership was transferred to a new individual who republished without the source. The npm package is technically alive but the development history is inaccessible.

Fix: Check the repository field in npm view [pkg] repository: if it returns a 404 on GitHub or the repo is private, treat it as High risk regardless of publish date. You're now depending on a binary you can't audit.

npm view express repository.url
# git+https://github.com/expressjs/express.git
# Then verify the repo is accessible:
curl -s -o /dev/null -w "%{http_code}" https://github.com/expressjs/express
# 200 = OK, 404 = deleted, 301 to a different URL = transferred

Error: Best fork has no npm release — how to install it

Cause: The community fork you identified in Step 4 has commits and a working codebase but the maintainer hasn't published to npm.

Fix: Install directly from GitHub using the git+https or shorthand syntax. Commit the package-lock.json change so the install is reproducible:

{
  "dependencies": {
    "abandoned-pkg": "github:active-fork-owner/abandoned-pkg#v2.1.0"
  }
}

Always pin to a tag or a full commit SHA, never a branch name — branch HEAD is mutable and breaks reproducibility:

{
  "dependencies": {
    "abandoned-pkg": "github:active-fork-owner/abandoned-pkg#a3f1d2c"
  }
}

Note: A transferred npm package is a subtle trap. If a company sold its npm package to a new owner, npm view [pkg] shows a recent publish date and looks fully healthy. Always cross-check the publisher name (npm view [pkg] _npmUser) against the GitHub org to catch ownership changes.


FAQ

Q: How old does a package need to be before it's considered abandoned?

There's no single threshold — use the risk matrix from Step 2. That said, 2 years without a publish combined with unanswered security issues is a hard red flag regardless of how stable the package feels. A package at 18 months with zero open issues and a clear "feature complete" note in the README is much lower risk than a 6-month-old authentication library whose maintainer stopped responding to CVE reports. The time dimension matters less than the issue response dimension for security-adjacent packages.

Q: Should I be worried about packages that are stable and rarely updated?

It depends entirely on what the package does. A pure utility — something that converts camelCase to snake_case, parses a static file format, or implements a stable algorithm — can legitimately be "done." No new features needed, no new threat surface. Worry calibrates to the package's attack surface: if it makes network requests, handles authentication tokens, parses untrusted input, or runs as part of your build toolchain with file-system access, it needs active maintenance even if the API hasn't changed. Stability in a utility is a feature; silence in infrastructure is a warning.

Q: What's the safest way to take over an abandoned npm package officially?

npm's official process starts at the npm support page where you file a package transfer request, providing evidence of abandonment (no response to issues for 6+ months, no publishes). For packages with an active GitHub repo but unresponsive maintainer, check the pkg-request repository on GitHub — it's a community-run tracker for handover requests where you can open an issue requesting stewardship. If the original maintainer is simply unreachable rather than hostile, npm will typically transfer after a waiting period. If the repo shows signs of a succession deadlock (maintainer actively blocking transfers), your cleanest option is publishing under a scoped name as shown in Step 5 Option C and building adoption from there.