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:
- Parse
package.jsonto extract all dependency types (production, dev, peer, optional) - Query the npm registry API to fetch the latest version, publish dates, and deprecation status for each package
- Query the npm security advisory bulk endpoint to find known vulnerabilities
- Compute a risk level for each dependency based on multiple signals
- Output a formatted terminal report with color-coded risk indicators
- 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
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
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"]
}
Add the shebang and bin field to package.json:
{
"bin": {
"depcheck-ai": "dist/index.js"
}
}
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;
}
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}`));
});
});
}
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;
}
}
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"
}
}
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();
});
}
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';
}
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';
}
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;
}
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();
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
Step 8: Build and Test
Compile and test locally:
npm run build
node dist/index.js .
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
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.jsonfor safe patch/minor upgrades and runnpm 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
Then run it in any Node.js project:
depcheck-ai
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)