DEV Community

Wilson Xu
Wilson Xu

Posted on

Build a Dependency Audit CLI Tool with Node.js (Step-by-Step)

Your node_modules folder is a liability. Here's how to build a tool that tells you exactly what's wrong with it.


Every Node.js project starts with npm install. Within minutes, your innocent-looking package.json pulls in hundreds -- sometimes thousands -- of transitive dependencies. And every single one of them is a potential attack vector.

In 2021, the ua-parser-js supply chain attack compromised a package with 8 million weekly downloads. In 2022, the colors and faker sabotage wiped out CI pipelines across the industry. In 2024, the xz-utils backdoor showed that even trusted maintainers can be social-engineered. These aren't theoretical risks. They're Tuesday.

Running npm audit helps, but it only covers known vulnerabilities. It won't tell you that 40% of your dependencies haven't been updated in two years, or that three of your production packages are deprecated. You need a tool that gives you the full picture: outdated versions, security advisories, deprecation warnings, and actionable upgrade paths -- all in one scan.

In this tutorial, we'll build exactly that. A CLI tool called depcheck-ai that reads your package.json, queries the npm registry and advisory APIs, and produces a color-coded audit report with risk levels and upgrade suggestions. By the end, you'll have a working tool you can run against any Node.js project.

What We're Building

Our CLI tool will:

  1. Parse package.json to extract all dependency types (production, dev, peer, optional)
  2. Query the npm registry API to fetch the latest version, publish dates, and deprecation status for each package
  3. Query the npm security advisory bulk endpoint to find known vulnerabilities
  4. Compute a risk level for each dependency based on multiple signals
  5. Output a formatted terminal report with color-coded risk indicators
  6. Optionally export an HTML report

Here's what the output looks like when you run it against a real project:

  depcheck-ai  Dependency Audit Report

  Project: my-app v1.2.0
  Scanned: 2026-03-19T14:30:00.000Z

  Summary
  Total dependencies:   24
  Outdated:             8
  Vulnerable:           2
  Deprecated:           1
  Overall health:       WARNING
Enter fullscreen mode Exit fullscreen mode

Prerequisites

You'll need Node.js 16+ and a basic understanding of TypeScript. We'll use these dependencies:

  • commander -- for CLI argument parsing
  • chalk -- for terminal colors
  • ora -- for loading spinners
  • cli-table3 -- for formatted tables
  • semver -- for version comparison logic

Step 1: Project Setup

Initialize the project and install dependencies:

mkdir depcheck-ai && cd depcheck-ai
npm init -y
npm install commander chalk@4 ora@5 cli-table3 semver
npm install -D typescript @types/node @types/semver
Enter fullscreen mode Exit fullscreen mode

Note: We pin chalk@4 and ora@5 because these are the last versions that support CommonJS require(). The newer ESM-only versions add complexity without benefit for a CLI tool.

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

Add the shebang and bin field to package.json:

{
  "bin": {
    "depcheck-ai": "dist/index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Define the Data Types

Before writing any logic, let's define the shapes of data we'll be working with. This is one of TypeScript's biggest advantages for CLI tools -- you catch structural errors at compile time instead of at runtime in production.

// src/index.ts
#!/usr/bin/env node

import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import Table from 'cli-table3';
import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import * as semver from 'semver';

interface PackageJson {
  name?: string;
  version?: string;
  dependencies?: Record<string, string>;
  devDependencies?: Record<string, string>;
  peerDependencies?: Record<string, string>;
  optionalDependencies?: Record<string, string>;
}

interface NpmRegistryInfo {
  name: string;
  'dist-tags': Record<string, string>;
  versions: Record<string, any>;
  time: Record<string, string>;
}

interface Advisory {
  id: number;
  ghsa_id: string;
  severity: 'low' | 'moderate' | 'high' | 'critical';
  summary: string;
  vulnerable_versions: string;
  patched_versions: string | null;
  url: string;
}

interface DependencyReport {
  name: string;
  currentSpec: string;
  currentResolved: string | null;
  latestVersion: string;
  wantedVersion: string;
  updateType: 'major' | 'minor' | 'patch' | 'prerelease' | 'current' | 'unknown';
  isOutdated: boolean;
  daysSinceLatest: number;
  depType: 'prod' | 'dev' | 'peer' | 'optional';
  advisories: Advisory[];
  suggestedUpgrade: string | null;
  riskLevel: 'safe' | 'low' | 'medium' | 'high' | 'critical';
  lastPublished: string;
  deprecated?: string;
}
Enter fullscreen mode Exit fullscreen mode

The key type here is DependencyReport. Each dependency in your project gets one of these. It captures everything you'd want to know: the current version, the latest available version, whether there are security advisories, and a computed risk level.

Step 3: Query the npm Registry API

The npm registry exposes a public JSON API at https://registry.npmjs.org/<package-name>. No authentication required. For each package, you get back the full version history, dist-tags (like latest), publish timestamps, and deprecation notices.

First, a simple HTTP helper using Node.js built-in https module (no need for node-fetch or axios for this):

function httpGet(url: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const req = https.get(url, {
      headers: { 'User-Agent': 'depcheck-ai/1.0.0' }
    }, (res) => {
      // Handle redirects
      if (res.statusCode && res.statusCode >= 300 &&
          res.statusCode < 400 && res.headers.location) {
        httpGet(res.headers.location).then(resolve).catch(reject);
        return;
      }
      let data = '';
      res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
      res.on('end', () => {
        if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
          resolve(data);
        } else {
          reject(new Error(`HTTP ${res.statusCode}: ${url}`));
        }
      });
    });
    req.on('error', reject);
    req.setTimeout(15000, () => {
      req.destroy();
      reject(new Error(`Timeout: ${url}`));
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Now the registry lookup function:

async function fetchPackageInfo(packageName: string): Promise<NpmRegistryInfo | null> {
  try {
    const encodedName = packageName.replace('/', '%2f');
    const data = await httpGet(`https://registry.npmjs.org/${encodedName}`);
    return JSON.parse(data);
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The replace('/', '%2f') handles scoped packages like @babel/core -- the slash needs to be URL-encoded.

The response from the registry looks like this (abbreviated):

{
  "name": "express",
  "dist-tags": { "latest": "4.21.2" },
  "versions": {
    "4.21.1": { ... },
    "4.21.2": { ... }
  },
  "time": {
    "4.21.2": "2024-11-01T10:30:00.000Z",
    "modified": "2024-11-01T10:30:00.000Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

From this, we extract the latest version (dist-tags.latest), all available versions (to match semver ranges), and the publish date (for staleness checks).

Step 4: Check the npm Advisory Database

npm exposes a bulk advisory endpoint at /-/npm/v1/security/advisories/bulk. You POST a JSON object where keys are package names and values are version ranges. It returns any known security advisories that match.

async function fetchAdvisories(
  deps: Record<string, string>
): Promise<Map<string, Advisory[]>> {
  const advisoryMap = new Map<string, Advisory[]>();
  const payload = JSON.stringify(deps);

  return new Promise((resolve) => {
    const postData = Buffer.from(payload);
    const options = {
      hostname: 'registry.npmjs.org',
      path: '/-/npm/v1/security/advisories/bulk',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': postData.length,
        'User-Agent': 'depcheck-ai/1.0.0',
      },
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
      res.on('end', () => {
        try {
          const result = JSON.parse(data);
          for (const [pkg, advs] of Object.entries(result)) {
            if (Array.isArray(advs) && advs.length > 0) {
              advisoryMap.set(pkg, advs.map((a: any) => ({
                id: a.id || 0,
                ghsa_id: a.github_advisory_id || '',
                severity: a.severity || 'moderate',
                summary: a.title || a.overview || 'Unknown advisory',
                vulnerable_versions: a.vulnerable_versions || '*',
                patched_versions: a.patched_versions || null,
                url: a.url || `https://github.com/advisories/${a.github_advisory_id || ''}`,
              })));
            }
          }
        } catch {
          // Graceful degradation: no advisories is better than a crash
        }
        resolve(advisoryMap);
      });
    });

    req.on('error', () => resolve(advisoryMap));
    req.setTimeout(20000, () => { req.destroy(); resolve(advisoryMap); });
    req.write(postData);
    req.end();
  });
}
Enter fullscreen mode Exit fullscreen mode

This is one of the most powerful and least-known npm APIs. Instead of checking each package individually, you send all your dependencies in a single request. The response is a map from package names to arrays of advisory objects, each containing the severity level, a description, the vulnerable version range, and a patched version (if one exists).

Notice the defensive coding pattern: if the endpoint fails or returns invalid JSON, we resolve with an empty map rather than rejecting. A CLI tool that crashes because the advisory API is temporarily down is worse than one that reports "no advisories found."

Step 5: Analyze Each Dependency

Now comes the core logic. For each dependency, we combine the registry info and advisory data to produce a comprehensive report.

First, version resolution -- translating a semver range like ^4.18.0 into an actual version:

function resolveVersionFromSpec(
  spec: string,
  availableVersions: string[]
): string | null {
  const cleaned = semver.coerce(spec);
  if (cleaned) return cleaned.version;
  return semver.maxSatisfying(availableVersions, spec);
}

function determineUpdateType(
  current: string,
  latest: string
): 'major' | 'minor' | 'patch' | 'prerelease' | 'current' | 'unknown' {
  if (!semver.valid(current) || !semver.valid(latest)) return 'unknown';
  if (semver.eq(current, latest)) return 'current';
  if (semver.major(current) < semver.major(latest)) return 'major';
  if (semver.minor(current) < semver.minor(latest)) return 'minor';
  if (semver.patch(current) < semver.patch(latest)) return 'patch';
  if (semver.prerelease(latest)) return 'prerelease';
  return 'unknown';
}
Enter fullscreen mode Exit fullscreen mode

Then, risk scoring -- this is where we turn raw data into actionable intelligence:

function computeRiskLevel(report: Partial<DependencyReport>): DependencyReport['riskLevel'] {
  if (report.advisories?.some(a => a.severity === 'critical')) return 'critical';
  if (report.advisories?.some(a => a.severity === 'high')) return 'high';
  if (report.deprecated) return 'high';
  if (report.advisories && report.advisories.length > 0) return 'medium';
  if (report.updateType === 'major' && (report.daysSinceLatest ?? 0) > 365) return 'medium';
  if (report.updateType === 'major') return 'low';
  if (report.isOutdated) return 'low';
  return 'safe';
}
Enter fullscreen mode Exit fullscreen mode

The risk hierarchy is intentional. A critical CVE always trumps everything else. A deprecated package is treated as high risk even without a CVE, because deprecated packages stop receiving security patches. A major version behind is low risk on its own, but medium risk if the latest version was published over a year ago (meaning you've been behind for a long time).

Step 6: Process Dependencies with Concurrency

Querying the npm registry for each package serially would be painfully slow. We process dependencies in batches:

async function analyzeDependencies(
  deps: Record<string, string>,
  depType: DependencyReport['depType'],
  advisoryMap: Map<string, Advisory[]>,
  spinner: any,
  concurrency: number = 8,
): Promise<DependencyReport[]> {
  const reports: DependencyReport[] = [];
  const entries = Object.entries(deps);

  for (let i = 0; i < entries.length; i += concurrency) {
    const batch = entries.slice(i, i + concurrency);
    spinner.text = `Checking ${depType} dependencies... (${Math.min(i + concurrency, entries.length)}/${entries.length})`;

    const batchResults = await Promise.all(
      batch.map(async ([name, spec]) => {
        const info = await fetchPackageInfo(name);
        if (!info) {
          return { /* fallback report with 'unknown' values */ };
        }

        const latest = info['dist-tags']?.latest || 'unknown';
        const allVersions = Object.keys(info.versions || {});
        const currentResolved = resolveVersionFromSpec(spec, allVersions);
        const wanted = semver.maxSatisfying(allVersions, spec) || latest;
        const isOutdated = !!(currentResolved && semver.valid(currentResolved)
          && semver.valid(latest) && semver.lt(currentResolved, latest));
        const updateType = currentResolved
          ? determineUpdateType(currentResolved, latest) : 'unknown';

        // Check for deprecation in the latest version metadata
        const latestVersionData = info.versions?.[latest];
        const deprecated = latestVersionData?.deprecated;

        const partial = {
          name, currentSpec: spec, currentResolved, latestVersion: latest,
          wantedVersion: wanted, updateType, isOutdated, depType,
          advisories: advisoryMap.get(name) || [], deprecated,
          daysSinceLatest: info.time?.[latest]
            ? daysBetween(info.time[latest]) : 0,
          lastPublished: info.time?.[latest] || 'unknown',
        };

        return {
          ...partial,
          riskLevel: computeRiskLevel(partial),
          suggestedUpgrade: suggestUpgrade(partial),
        } as DependencyReport;
      }),
    );

    reports.push(...batchResults);
  }

  return reports;
}
Enter fullscreen mode Exit fullscreen mode

The batch size of 8 is a good default. Going higher risks hitting npm's rate limits; going lower makes the scan unnecessarily slow.

Step 7: Wire Up the CLI

Finally, we use commander to expose this as a proper CLI tool:

const program = new Command();

program
  .name('depcheck-ai')
  .description('Dependency audit tool - scan for outdated, vulnerable, and risky dependencies')
  .version('1.0.0')
  .argument('[path]', 'path to project or package.json', '.')
  .option('--json', 'output results as JSON')
  .option('--html <file>', 'save HTML report to file')
  .option('-v, --verbose', 'show detailed output with upgrade suggestions')
  .option('--dev', 'only scan devDependencies')
  .option('--prod', 'only scan production dependencies')
  .option('--peer', 'also scan peerDependencies')
  .action(async (targetPath: string, options) => {
    try {
      await scan(targetPath, options);
    } catch (err: any) {
      console.error(chalk.red(`Error: ${err.message}`));
      process.exit(1);
    }
  });

program.parse();
Enter fullscreen mode Exit fullscreen mode

The scan function (which orchestrates everything) reads package.json, calls fetchAdvisories for the bulk security check, then calls analyzeDependencies for each dependency section, and finally renders the report.

A key design decision: the tool uses process exit codes to communicate risk levels. Exit code 0 means healthy, 1 means warning (vulnerabilities found), and 2 means danger (critical issues). This makes it trivially easy to integrate into CI/CD pipelines:

# In your GitHub Actions workflow
- name: Audit dependencies
  run: npx @chengyixu/depcheck-ai --prod
  # Fails the build if vulnerabilities are found
Enter fullscreen mode Exit fullscreen mode

Step 8: Build and Test

Compile and test locally:

npm run build
node dist/index.js .
Enter fullscreen mode Exit fullscreen mode

You should see a formatted report for your own project. Try it on a project with known issues to see the full output:

# Scan a specific project
npx @chengyixu/depcheck-ai /path/to/project

# JSON output for programmatic consumption
npx @chengyixu/depcheck-ai --json

# Generate an HTML report
npx @chengyixu/depcheck-ai --html audit-report.html

# Only production dependencies
npx @chengyixu/depcheck-ai --prod
Enter fullscreen mode Exit fullscreen mode

Going Further

Here are some ideas to extend this tool:

  • License auditing: The npm registry includes license info for each version. You could flag packages with incompatible licenses (e.g., GPL in an MIT project).
  • Dependency graph analysis: Detect circular dependencies or single points of failure (packages that everything depends on).
  • SBOM generation: Export a Software Bill of Materials in CycloneDX or SPDX format for compliance requirements.
  • Auto-fix mode: Automatically update package.json for safe patch/minor upgrades and run npm install.

Why This Matters

Every dependency you add is code you didn't write, maintained by people you don't know, funded by goodwill that can evaporate at any time. The median npm package has 79 transitive dependencies. That's 79 opportunities for a supply chain attack, a breaking change, or an abandoned maintainer.

Tools like this don't eliminate the risk. But they make the risk visible. And visible risks are manageable risks.

Try It Now

The complete tool is published and ready to use:

npm install -g @chengyixu/depcheck-ai
Enter fullscreen mode Exit fullscreen mode

Then run it in any Node.js project:

depcheck-ai
Enter fullscreen mode Exit fullscreen mode

Source code: github.com/chengyixu/depcheck-ai


If you found this useful, consider starring the repo and sharing it with your team. Supply chain security is a team sport.

Top comments (0)