As we know, npm packages are getting hacked day by day.
From malicious package injections to supply chain attacks, dependency management is no longer just about keeping things updated β itβs about security, visibility, and control.
So I built a Smart Dependency Report System that gives complete visibility into your dependencies.
π§© The Real Problem Isnβt Dependencies β Itβs Visibility
Most developers rely on:
- npm outdated
- npm audit
But letβs be honestβthese tools are:
- β Noisy
- β Hard to prioritize
- β Not decision-friendly
They tell you everything, but help you decide nothing.
You donβt need a list.
You need clarity.
π§ What This System Does
- Classifies updates (Major / Minor / Patch)
- Detects vulnerabilities via OSV.dev
- Fetches npm metadata (size, publish date)
- Handles git/file/workspace dependencies
- Generates CLI + PDF reports
- Provides actionable recommendations
π¦ Installation & Setup
To get started with the Smart Dependency Report System, install the required dependencies:
yarn add --dev chalk cli-table3 semver pdfkit
Note:
Please update this path based on the location where youβve placed the dependency-report folder.
/** Repo root (directory that contains package.json). */
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
π Project Structure
src/
βββ config/
βββ scripts/
βββ dependency-report/
βββ analyzeVersions.js
βββ badges.js
βββ constants.js
βββ generate-report.js
βββ npmAuditSecurity.js
βββ npmRegistry.js
βββ osvSecurity.js
βββ readPackages.js
βββ reportConsole.js
βββ reportPdf.js
βββ reportSummary.js
βββ runReport.js
βοΈ Code Walkthrough (File by File)
π generate-report.js (Entry Point)
#!/usr/bin/env node
/**
* React Native dependency report: compares installed versions to npm "latest",
* prints a CLI table, and writes report/dependency-report.pdf under the project root.
*
* Usage:
* npm run report:deps
* node src/config/scripts/dependency-report/generate-report.js
* node src/config/scripts/dependency-report/generate-report.js --major-only
* node src/config/scripts/dependency-report/generate-report.js --sort=update-type
*
* Security: vulnerabilities come from OSV.dev (https://api.osv.dev) using package-lock.json
* when present, otherwise installed versions from node_modules for direct dependencies.
*
* Optional env:
* DEPENDENCY_REPORT_OSV_API=https://api.osv.dev (override OSV API base URL)
* DEPENDENCY_REPORT_OSV_TIMEOUT_MS=120000 (HTTP timeout for OSV calls)
* REPORT_SKIP_AUDIT=1 (skip OSV; Secure column shows unknown)
* DEPENDENCY_REPORT_SECURE_ICON_URL=... (PDF secure-cell image, optional)
*/
const path = require('path');
const { run } = require('./runReport');
/** Repo root (directory that contains package.json). */
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
function printHelp() {
console.log(`
React Native Dependency Report
Usage:
npm run report:deps
node src/config/scripts/dependency-report/generate-report.js [options]
Options:
--major-only Only include packages with a semver MAJOR update available
--sort=update-type Sort by update severity (Major β Minor β Patch β β¦), then name
--sort=name Sort alphabetically by package name (default)
--help, -h Show this help
Outputs:
- Table printed to stdout
- report/dependency-report.pdf (project root)
Environment:
DEPENDENCY_REPORT_OSV_API OSV API base (default: https://api.osv.dev)
DEPENDENCY_REPORT_OSV_TIMEOUT_MS Timeout ms for OSV HTTP requests (default: 120000)
REPORT_SKIP_AUDIT Set to 1 to skip OSV and mark Secure as unknown
DEPENDENCY_REPORT_SECURE_ICON_URL Optional HTTPS URL to PNG/JPEG for the PDF secure cell
`);
}
/**
* @param {string[]} argv
*/
function parseArgs(argv) {
const opts = {
majorOnly: false,
sort: /** @type {'name' | 'update-type'} */ ('name'),
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
return { ...opts, help: true };
}
if (arg === '--major-only') {
opts.majorOnly = true;
continue;
}
if (arg.startsWith('--sort=')) {
const v = arg.slice('--sort='.length).toLowerCase();
if (v === 'update-type' || v === 'type') {
opts.sort = 'update-type';
} else if (v === 'name' || v === 'package') {
opts.sort = 'name';
} else {
console.warn(`Unknown --sort value "${v}", using name.`);
opts.sort = 'name';
}
continue;
}
console.warn(`Ignoring unknown argument: ${arg}`);
}
return { ...opts, help: false };
}
async function main() {
const parsed = parseArgs(process.argv);
if (parsed.help) {
printHelp();
process.exit(0);
}
try {
const { pdfPath } = await run(PROJECT_ROOT, {
majorOnly: parsed.majorOnly,
sort: parsed.sort,
});
console.log(`\nPDF written to: ${pdfPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('Dependency report failed:', message);
process.exitCode = 1;
}
}
main();
π This is the CLI entry point that triggers everything.
π runReport.js (Core Orchestrator)
/**
* Orchestrates reading package.json, querying npm, sorting/filtering, and rendering outputs.
*/
const fs = require('fs');
const path = require('path');
const {
collectDeclaredPackages,
readInstalledVersion,
} = require('./readPackages');
const { fetchPackageMeta } = require('./npmRegistry');
const {
classifyUpdateType,
describeNonRegistryRow,
} = require('./analyzeVersions');
const { UPDATE_TYPE, UPDATE_TYPE_SORT_ORDER } = require('./constants');
const { attachSecurityFromOsv } = require('./osvSecurity');
const { printConsoleTable, printConsoleSummary } = require('./reportConsole');
const { writePdfReport } = require('./reportPdf');
/**
* @typedef {object} ReportRow
* @property {string} name
* @property {'dependencies'|'devDependencies'} kind
* @property {string} wantedRange
* @property {string | null} installedVersion
* @property {string | null} latestVersion
* @property {string | null} lastPublished
* @property {number | null} unpackedSizeBytes
* @property {string} updateType
* @property {string | null} [registryError]
* @property {'none'|'info'|'low'|'moderate'|'high'|'critical'|'unknown'} [securityLevel]
* @property {number} [securityCount]
* @property {string} [securityTooltip]
*/
/**
* Run async work in chunks to limit concurrent registry calls.
* @template T, R
* @param {T[]} items
* @param {number} limit
* @param {(item: T, index: number) => Promise<R>} fn
* @returns {Promise<R[]>}
*/
async function mapPool(items, limit, fn) {
const results = new Array(items.length);
let i = 0;
const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
while (i < items.length) {
const idx = i++;
results[idx] = await fn(items[idx], idx);
}
});
await Promise.all(workers);
return results;
}
/**
* @param {string} projectRoot
* @param {{ majorOnly?: boolean, sort?: 'name' | 'update-type', pdfPath?: string, concurrency?: number }} options
* @returns {Promise<{ rows: ReportRow[], pdfPath: string }>}
*/
async function run(projectRoot, options = {}) {
const {
majorOnly = false,
sort = 'name',
pdfPath = path.join(projectRoot, 'report', 'dependency-report.pdf'),
concurrency = 12,
} = options;
const declared = collectDeclaredPackages(projectRoot);
/** @type {ReportRow[]} */
const rows = await mapPool(declared, concurrency, async (entry) => {
const installed = readInstalledVersion(projectRoot, entry.name);
const { skipRegistry, note } = describeNonRegistryRow(entry.wantedRange);
if (skipRegistry) {
return {
name: entry.name,
kind: entry.kind,
wantedRange: entry.wantedRange,
installedVersion: installed,
latestVersion: null,
lastPublished: null,
unpackedSizeBytes: null,
updateType: UPDATE_TYPE.UNKNOWN,
registryError: note,
};
}
const meta = await fetchPackageMeta(entry.name);
const latest = meta.latestVersion;
let updateType = UPDATE_TYPE.UNKNOWN;
if (latest) {
updateType = classifyUpdateType(installed, latest);
} else if (meta.error) {
updateType = UPDATE_TYPE.UNKNOWN;
}
return {
name: entry.name,
kind: entry.kind,
wantedRange: entry.wantedRange,
installedVersion: installed,
latestVersion: latest,
lastPublished: meta.lastPublished,
unpackedSizeBytes: meta.unpackedSizeBytes,
updateType,
registryError: meta.error,
};
});
if (process.env.REPORT_SKIP_AUDIT === '1') {
for (const row of rows) {
row.securityLevel = 'unknown';
row.securityCount = 0;
row.securityTooltip = 'Skipped OSV (REPORT_SKIP_AUDIT=1)';
}
} else {
await attachSecurityFromOsv(projectRoot, rows);
}
let filtered = rows;
if (majorOnly) {
filtered = rows.filter((r) => r.updateType === UPDATE_TYPE.MAJOR);
}
const sorted = [...filtered].sort((a, b) => {
if (sort === 'update-type') {
const oa =
UPDATE_TYPE_SORT_ORDER[a.updateType] ?? UPDATE_TYPE_SORT_ORDER[UPDATE_TYPE.UNKNOWN];
const ob =
UPDATE_TYPE_SORT_ORDER[b.updateType] ?? UPDATE_TYPE_SORT_ORDER[UPDATE_TYPE.UNKNOWN];
if (oa !== ob) {
return oa - ob;
}
}
return a.name.localeCompare(b.name, 'en');
});
printConsoleTable(sorted);
printConsoleSummary(sorted);
const flagged = sorted.filter((r) => r.registryError);
if (flagged.length > 0) {
console.warn(
`\nNote: ${flagged.length} package(s) have registry/spec limitations (git/file/link or fetch errors).`,
);
}
fs.mkdirSync(path.dirname(pdfPath), { recursive: true });
await writePdfReport(pdfPath, sorted);
return { rows: sorted, pdfPath };
}
module.exports = { run, mapPool };
π This file:
- Reads dependencies
- Fetches registry data
- Classifies updates
- Attaches security
- Generates output
π readPackages.js (Dependency Reader)
/**
* Load dependencies from package.json and resolve installed versions from node_modules.
*/
const fs = require('fs');
const path = require('path');
/**
* @param {string} projectRoot
* @returns {{ name: string, wantedRange: string, kind: 'dependencies' | 'devDependencies' }[]}
*/
function collectDeclaredPackages(projectRoot) {
const pkgPath = path.join(projectRoot, 'package.json');
const raw = fs.readFileSync(pkgPath, 'utf8');
const pkg = JSON.parse(raw);
const out = [];
const deps = pkg.dependencies || {};
for (const [name, wantedRange] of Object.entries(deps)) {
out.push({
name,
wantedRange: String(wantedRange),
kind: 'dependencies',
});
}
const devDeps = pkg.devDependencies || {};
for (const [name, wantedRange] of Object.entries(devDeps)) {
out.push({
name,
wantedRange: String(wantedRange),
kind: 'devDependencies',
});
}
return out;
}
/**
* Read version from node_modules/<name>/package.json (supports scoped names).
* @param {string} projectRoot
* @param {string} packageName
* @returns {string | null}
*/
function readInstalledVersion(projectRoot, packageName) {
const parts = packageName.startsWith('@')
? packageName.split('/')
: [packageName];
const rel =
parts.length === 2
? path.join('node_modules', parts[0], parts[1])
: path.join('node_modules', parts[0]);
const installedPkgPath = path.join(projectRoot, rel, 'package.json');
try {
const data = JSON.parse(fs.readFileSync(installedPkgPath, 'utf8'));
return typeof data.version === 'string' ? data.version : null;
} catch {
return null;
}
}
/**
* Heuristic: npm registry cannot resolve arbitrary git/file/tar URLs as "latest" the same way.
* @param {string} range
*/
function isNonRegistrySpec(range) {
return (
/^(file:|git\+|git:|github:|https?:\/\/|link:|workspace:|workspace:\*)/i.test(
range.trim(),
) || range.includes('/')
);
}
module.exports = {
collectDeclaredPackages,
readInstalledVersion,
isNonRegistrySpec,
};
π Extracts both declared and installed versions.
π npmRegistry.js (Registry Fetcher)
/**
* Fetch package metadata from the public npm registry (using axios).
*/
const axios = require('axios');
const REGISTRY_BASE = 'https://registry.npmjs.org';
const REQUEST_TIMEOUT_MS = 25_000;
/**
* @typedef {object} RegistryMeta
* @property {string | null} latestVersion
* @property {string | null} lastPublished
* @property {number | null} unpackedSizeBytes
* @property {string | null} error
*/
/**
* @param {string} packageName
* @returns {Promise<RegistryMeta>}
*/
async function fetchPackageMeta(packageName) {
// Scoped names: encode @ and / per npm registry (e.g. @babel/core -> %40babel%2Fcore)
const url = `${REGISTRY_BASE}/${encodeURIComponent(packageName)}`;
try {
const { data } = await axios.get(url, {
timeout: REQUEST_TIMEOUT_MS,
headers: { Accept: 'application/json' },
// Full document is needed for versions[ver].dist and time
validateStatus: (s) => s === 200 || s === 404,
});
if (data == null || data.error || !data['dist-tags']) {
return {
latestVersion: null,
lastPublished: null,
unpackedSizeBytes: null,
error: data?.error || 'Not found or invalid registry response',
};
}
const latest = data['dist-tags']?.latest;
if (!latest || typeof latest !== 'string') {
return {
latestVersion: null,
lastPublished: null,
unpackedSizeBytes: null,
error: 'No dist-tags.latest',
};
}
const timeMap = data.time || {};
const lastPublished =
typeof timeMap[latest] === 'string' ? timeMap[latest] : null;
const verInfo = data.versions?.[latest];
const unpacked =
verInfo?.dist && typeof verInfo.dist.unpackedSize === 'number'
? verInfo.dist.unpackedSize
: null;
return {
latestVersion: latest,
lastPublished,
unpackedSizeBytes: unpacked,
error: null,
};
} catch (err) {
const message =
err.response?.status === 404
? 'Package not found on registry'
: err.message || String(err);
return {
latestVersion: null,
lastPublished: null,
unpackedSizeBytes: null,
error: message,
};
}
}
module.exports = { fetchPackageMeta, REGISTRY_BASE };
π Fetches:
- Latest version
- Publish date
- Package size
π analyzeVersions.js (Version Intelligence)
/**
* Compare installed vs latest using semver and classify update type.
*/
const semver = require('semver');
const { UPDATE_TYPE } = require('./constants');
const { isNonRegistrySpec } = require('./readPackages');
/**
* @param {string | null} installed
* @param {string | null} latest
* @returns {typeof UPDATE_TYPE[keyof typeof UPDATE_TYPE]}
*/
function classifyUpdateType(installed, latest) {
if (!latest) {
return UPDATE_TYPE.UNKNOWN;
}
if (!installed || !semver.valid(installed)) {
return UPDATE_TYPE.UNKNOWN;
}
if (!semver.valid(latest)) {
return UPDATE_TYPE.UNKNOWN;
}
if (semver.eq(installed, latest)) {
return UPDATE_TYPE.UP_TO_DATE;
}
// If "latest" is not actually newer (e.g. user on newer prerelease), treat as unknown edge case
if (!semver.gt(latest, installed)) {
const pre = semver.prerelease(installed);
if (pre && semver.eq(semver.coerce(installed), semver.coerce(latest))) {
return UPDATE_TYPE.PRERELEASE;
}
return UPDATE_TYPE.UNKNOWN;
}
const diff = semver.diff(installed, latest);
if (diff === 'major') {
return UPDATE_TYPE.MAJOR;
}
if (diff === 'minor') {
return UPDATE_TYPE.MINOR;
}
if (diff === 'patch') {
return UPDATE_TYPE.PATCH;
}
if (diff === 'prerelease') {
return UPDATE_TYPE.PRERELEASE;
}
return UPDATE_TYPE.UNKNOWN;
}
function describeNonRegistryRow(wantedRange) {
if (isNonRegistrySpec(wantedRange)) {
return {
skipRegistry: true,
note: 'Non-registry spec (git/file/link/workspace)',
};
}
return { skipRegistry: false, note: null };
}
module.exports = {
classifyUpdateType,
describeNonRegistryRow,
};
π Converts raw versions into actionable insights.
π osvSecurity.js (Security Engine)
/**
* Map installed npm packages to security status using the OSV.dev API (Open Source Vulnerabilities).
* @see https://google.github.io/osv.dev/api/
*/
const fs = require('fs');
const path = require('path');
const OSV_DEFAULT_BASE = 'https://api.osv.dev';
const OSV_ECOSYSTEM = 'npm';
const BATCH_SIZE = 150;
const VULN_FETCH_CONCURRENCY = 12;
const REQUEST_TIMEOUT_MS =
Number(process.env.DEPENDENCY_REPORT_OSV_TIMEOUT_MS) || 120000;
/** @typedef {'none' | 'info' | 'low' | 'moderate' | 'high' | 'critical' | 'unknown'} SecurityLevel */
/**
* @param {SecurityLevel} a
* @param {SecurityLevel} b
* @returns {SecurityLevel}
*/
function maxSeverity(a, b) {
const rank = { none: 0, info: 1, low: 2, moderate: 3, high: 4, critical: 5, unknown: 0 };
const ra = rank[a] ?? 0;
const rb = rank[b] ?? 0;
return ra >= rb ? a : b;
}
/**
* @param {string} v
*/
function shouldSkipVersion(v) {
if (!v || typeof v !== 'string') {
return true;
}
const t = v.trim();
if (!t || t === 'null' || t === 'undefined') {
return true;
}
return /^(file:|git\+|git:|github:|link:|workspace:|\*)/i.test(t) || /^https?:\/\//i.test(t);
}
/**
* Derive package name from a package-lock `packages` path key (lockfile v2+).
* @param {string} key
*/
function nameFromLockPackagesKey(key) {
const k = key.replace(/\\/g, '/');
const i = k.lastIndexOf('node_modules/');
if (i === -1) {
return null;
}
return k.slice(i + 'node_modules/'.length) || null;
}
/**
* @param {string} projectRoot
* @returns {{ name: string, version: string }[] | null}
*/
function readPackageLockPairs(projectRoot) {
const lockPath = path.join(projectRoot, 'package-lock.json');
if (!fs.existsSync(lockPath)) {
return null;
}
let lock;
try {
lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
} catch {
return null;
}
/** @type {{ name: string, version: string }[]} */
const out = [];
const seen = new Set();
function add(name, version) {
if (!name || shouldSkipVersion(version)) {
return;
}
const k = `${name}@${version}`;
if (seen.has(k)) {
return;
}
seen.add(k);
out.push({ name, version: version.trim() });
}
if (lock.packages && typeof lock.packages === 'object') {
for (const [pkgPath, meta] of Object.entries(lock.packages)) {
if (!meta || typeof meta !== 'object' || !meta.version) {
continue;
}
const name = typeof meta.name === 'string' ? meta.name : nameFromLockPackagesKey(pkgPath);
add(name, meta.version);
}
} else if (lock.dependencies && typeof lock.dependencies === 'object') {
const walk = (deps) => {
if (!deps || typeof deps !== 'object') {
return;
}
for (const [name, node] of Object.entries(deps)) {
if (node && typeof node === 'object' && typeof node.version === 'string') {
add(name, node.version);
}
if (node && node.dependencies) {
walk(node.dependencies);
}
}
};
walk(lock.dependencies);
}
return out.length ? out : null;
}
/**
* @param {Array<{ name: string, installedVersion?: string | null }>} rows
*/
function pairsFromRows(rows) {
const seen = new Set();
/** @type {{ name: string, version: string }[]} */
const out = [];
for (const r of rows) {
const v = r.installedVersion;
if (!v || shouldSkipVersion(v)) {
continue;
}
const k = `${r.name}@${v}`;
if (seen.has(k)) {
continue;
}
seen.add(k);
out.push({ name: r.name, version: v.trim() });
}
return out;
}
/**
* @param {object} vuln
* @returns {SecurityLevel}
*/
function severityFromVulnRecord(vuln) {
if (!vuln || typeof vuln !== 'object') {
return 'moderate';
}
let best = 0;
const rank = { info: 1, low: 2, moderate: 3, high: 4, critical: 5 };
/** @param {string} raw */
const bumpFromLabel = (raw) => {
if (!raw || typeof raw !== 'string') {
return;
}
const s = raw.trim().toLowerCase();
let r = 0;
if (s === 'critical') {
r = 5;
} else if (s === 'high') {
r = 4;
} else if (s === 'moderate' || s === 'medium') {
r = 3;
} else if (s === 'low') {
r = 2;
} else if (s === 'info' || s === 'informational') {
r = 1;
}
if (r > best) {
best = r;
}
};
const dsTop = vuln.database_specific;
if (dsTop && typeof dsTop === 'object' && dsTop.severity) {
bumpFromLabel(String(dsTop.severity));
}
if (Array.isArray(vuln.severity)) {
for (const entry of vuln.severity) {
if (!entry || typeof entry !== 'object') {
continue;
}
const scoreStr = entry.score;
if (typeof scoreStr === 'string') {
const n = parseFloat(scoreStr);
if (!Number.isNaN(n)) {
if (n >= 9.0) {
best = Math.max(best, 5);
} else if (n >= 7.0) {
best = Math.max(best, 4);
} else if (n >= 4.0) {
best = Math.max(best, 3);
} else if (n > 0) {
best = Math.max(best, 2);
}
}
}
}
}
for (const aff of vuln.affected || []) {
if (!aff || typeof aff !== 'object') {
continue;
}
const ds = aff.database_specific;
const es = aff.ecosystem_specific;
if (ds && typeof ds === 'object' && ds.severity) {
bumpFromLabel(String(ds.severity));
}
if (es && typeof es === 'object' && es.severity) {
bumpFromLabel(String(es.severity));
}
}
if (best === 5) {
return 'critical';
}
if (best === 4) {
return 'high';
}
if (best === 3) {
return 'moderate';
}
if (best === 2) {
return 'low';
}
if (best === 1) {
return 'info';
}
return 'moderate';
}
/**
* @param {string} baseUrl
* @param {unknown} body
*/
async function osvPostJson(baseUrl, pathname, body) {
const url = `${baseUrl.replace(/\/$/, '')}${pathname}`;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'User-Agent': 'dependency-report/1.0 (+https://osv.dev)',
},
body: JSON.stringify(body),
signal: ac.signal,
});
clearTimeout(timer);
if (!res.ok) {
return null;
}
return await res.json();
} catch {
clearTimeout(timer);
return null;
}
}
/**
* @param {string} baseUrl
* @param {string} id
*/
async function osvGetVuln(baseUrl, id) {
const url = `${baseUrl.replace(/\/$/, '')}/v1/vulns/${encodeURIComponent(id)}`;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), Math.min(REQUEST_TIMEOUT_MS, 60000));
try {
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': 'dependency-report/1.0 (+https://osv.dev)',
},
signal: ac.signal,
});
clearTimeout(timer);
if (!res.ok) {
return null;
}
return await res.json();
} catch {
clearTimeout(timer);
return null;
}
}
/**
* @param {{ name: string, version: string }[]} queries
* @param {string} baseUrl
* @returns {Promise<{ ok: boolean, idLists: string[][] }>}
*/
async function runQueryBatches(queries, baseUrl) {
/** @type {string[][]} */
const allResults = [];
for (let i = 0; i < queries.length; i += BATCH_SIZE) {
const chunk = queries.slice(i, i + BATCH_SIZE);
const payload = {
queries: chunk.map((q) => ({
package: { ecosystem: OSV_ECOSYSTEM, name: q.name },
version: q.version,
})),
};
const json = await osvPostJson(baseUrl, '/v1/querybatch', payload);
if (!json || !Array.isArray(json.results) || json.results.length !== chunk.length) {
return { ok: false, idLists: [] };
}
for (let j = 0; j < chunk.length; j++) {
const r = json.results[j];
const vulns = Array.isArray(r?.vulns) ? r.vulns : [];
const ids = vulns.map((v) => (v && v.id ? String(v.id) : '')).filter(Boolean);
allResults.push(ids);
}
}
return { ok: true, idLists: allResults };
}
/**
* @param {string[]} ids
* @param {string} baseUrl
* @returns {Promise<Map<string, object>>}
*/
async function fetchVulnDetails(ids, baseUrl) {
const unique = [...new Set(ids)];
const map = new Map();
if (unique.length === 0) {
return map;
}
let next = 0;
async function worker() {
while (true) {
const j = next++;
if (j >= unique.length) {
break;
}
const id = unique[j];
const detail = await osvGetVuln(baseUrl, id);
if (detail) {
map.set(id, detail);
}
}
}
const nWorkers = Math.min(VULN_FETCH_CONCURRENCY, unique.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return map;
}
/**
* @param {{ name: string, version: string }[]} queries
* @param {string[][]} idLists
* @param {Map<string, object>} details
* @returns {Map<string, { level: SecurityLevel, count: number }>}
*/
function aggregateByPackageName(queries, idLists, details) {
/** @type {Map<string, Set<string>>} */
const idsByName = new Map();
for (let i = 0; i < queries.length; i++) {
const { name } = queries[i];
const ids = idLists[i] || [];
for (const id of ids) {
if (!idsByName.has(name)) {
idsByName.set(name, new Set());
}
idsByName.get(name).add(id);
}
}
/** @type {Map<string, { level: SecurityLevel, count: number }>} */
const out = new Map();
for (const [name, idSet] of idsByName) {
let level = /** @type {SecurityLevel} */ ('moderate');
for (const id of idSet) {
const vuln = details.get(id);
const sev = vuln ? severityFromVulnRecord(vuln) : 'moderate';
level = maxSeverity(level, sev);
}
out.set(name, { level, count: idSet.size });
}
return out;
}
/**
* @param {string} projectRoot
* @param {Array<{ name: string, installedVersion?: string | null }>} rows
* @returns {Promise<Map<string, { level: SecurityLevel, count: number }> | null>}
*/
async function fetchOsvSeverityMap(projectRoot, rows) {
const baseUrl = String(process.env.DEPENDENCY_REPORT_OSV_API || OSV_DEFAULT_BASE).trim() || OSV_DEFAULT_BASE;
const fromLock = readPackageLockPairs(projectRoot);
const queries = fromLock && fromLock.length ? fromLock : pairsFromRows(rows);
if (!queries.length) {
return null;
}
const { ok, idLists } = await runQueryBatches(queries, baseUrl);
if (!ok || idLists.length !== queries.length) {
return null;
}
const allIds = idLists.flat();
const details = await fetchVulnDetails(allIds, baseUrl);
return aggregateByPackageName(queries, idLists, details);
}
/**
* @param {SecurityLevel} level
* @param {number} count
*/
function securityTooltip(level, count) {
if (level === 'unknown') {
return 'Security check unavailable (OSV request failed or invalid response)';
}
if (level === 'none') {
return 'No known issues in OSV for queried versions';
}
const sev =
level === 'critical'
? 'Critical'
: level === 'high'
? 'High'
: level === 'moderate'
? 'Moderate'
: level === 'low'
? 'Low'
: 'Info';
return `${count} OSV record(s) β highest severity: ${sev}`;
}
/**
* @param {string} projectRoot
* @param {Array<{ name: string, installedVersion?: string | null }>} rows
*/
async function attachSecurityFromOsv(projectRoot, rows) {
const fromLock = readPackageLockPairs(projectRoot);
const queries = fromLock && fromLock.length ? fromLock : pairsFromRows(rows);
if (!queries.length) {
const msg =
'Security check skipped: add package-lock.json or install deps so versions are known for OSV';
for (const row of rows) {
row.securityLevel = 'unknown';
row.securityCount = 0;
row.securityTooltip = msg;
}
return;
}
let byPkg;
try {
byPkg = await fetchOsvSeverityMap(projectRoot, rows);
} catch {
byPkg = null;
}
if (byPkg == null) {
for (const row of rows) {
row.securityLevel = 'unknown';
row.securityCount = 0;
row.securityTooltip = securityTooltip('unknown', 0);
}
return;
}
for (const row of rows) {
const hit = byPkg.get(row.name);
if (hit) {
row.securityLevel = hit.level;
row.securityCount = hit.count;
} else {
row.securityLevel = 'none';
row.securityCount = 0;
}
row.securityTooltip = securityTooltip(row.securityLevel, row.securityCount);
}
}
module.exports = {
attachSecurityFromOsv,
fetchOsvSeverityMap,
securityTooltip,
readPackageLockPairs,
pairsFromRows,
severityFromVulnRecord,
};
π Uses OSV.dev to:
- Detect vulnerabilities
- Assign severity levels
π reportConsole.js (CLI Output)
/**
* CLI table: dark terminal + white text; Installed / Latest / Update use centered
* calm pill tags (light fills + darker text; ANSI bg only on the label).
*/
// Enable ANSI colors when supported (some IDEs need this before chalk loads).
if (!process.env.NO_COLOR) {
process.env.FORCE_COLOR = process.env.FORCE_COLOR || '3';
}
const chalk = require('chalk');
const Table = require('cli-table3');
const {
UPDATE_TYPE,
formatUpdateColumnLabel,
PDF_TABLE,
hexToRgb,
} = require('./constants');
const {
summarizeDependencyRows,
buildStructuredSummary,
capitalizeSeverity,
} = require('./reportSummary');
/** Default body cells: bright white on terminal background. */
function cellPlain(text) {
return chalk.white(String(text));
}
/** Table header: bold white (borders stay visible on dark terminals). */
function cellHeader(text) {
return chalk.white.bold(String(text));
}
/**
* @param {{ bg: string, fg: string }} style
* @param {string} text
*/
function cellPill(style, text) {
const [r, g, b] = hexToRgb(style.bg);
const pad = ' ';
return chalk.bgRgb(r, g, b).hex(style.fg).bold(`${pad}${text}${pad}`);
}
/** @param {string | null | undefined} version */
function cellInstalledVersion(version) {
const t = version ?? 'β';
if (t === 'β') {
return chalk.gray('β');
}
return cellPill(PDF_TABLE.installedPill, t);
}
/** @param {string | null | undefined} version */
function cellLatestVersion(version) {
const t = version ?? 'β';
if (t === 'β') {
return chalk.gray('β');
}
return cellPill(PDF_TABLE.latestPill, t);
}
/**
* @param {string} updateType
* @param {boolean} registryNote
*/
function cellUpdateType(updateType, registryNote) {
const label = formatUpdateColumnLabel(updateType, registryNote);
const style =
PDF_TABLE.updateBadge[updateType] ?? PDF_TABLE.updateBadge[UPDATE_TYPE.UNKNOWN];
return cellPill(style, label);
}
/**
* Terminal Secure column: compact βiconβ tile (saturated bg + light glyph) so it reads
* clearly on dark backgrounds β same idea as a green check badge in GUI tools.
* @param {string} bgHex
* @param {string} fgHex
* @param {string} symbol single character or short mark
*/
function cellSecureIcon(bgHex, fgHex, symbol) {
const [r, g, b] = hexToRgb(bgHex);
const pad = ' ';
return chalk.bgRgb(r, g, b).hex(fgHex).bold(`${pad}${symbol}${pad}`);
}
/** bg / fg / symbol for each OSV outcome (β = no known vulns, β = vulnerability, β = check unavailable). */
const GLYPH_SECURE = '\u2713';
const GLYPH_UNSECURE = '\u26A0';
const SECURE_ICON = {
none: { bg: '#2E7D50', fg: '#FFFFFF', symbol: GLYPH_SECURE },
unknown: { bg: '#546E7A', fg: '#ECEFF1', symbol: '\u2014' },
info: { bg: '#0277BD', fg: '#FFFFFF', symbol: 'i' },
low: { bg: '#B45309', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
moderate: { bg: '#EA580C', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
high: { bg: '#DC2626', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
critical: { bg: '#991B1B', fg: '#FFFFFF', symbol: GLYPH_UNSECURE },
};
/**
* @param {{
* securityLevel?: string,
* securityCount?: number,
* securityTooltip?: string
* }} r
*/
const SECURE_ICON_BY_LEVEL = {
none: SECURE_ICON.none,
unknown: SECURE_ICON.unknown,
info: SECURE_ICON.info,
low: SECURE_ICON.low,
moderate: SECURE_ICON.moderate,
high: SECURE_ICON.high,
critical: SECURE_ICON.critical,
};
function cellSecure(r) {
const level = r.securityLevel ?? 'unknown';
const pick = SECURE_ICON_BY_LEVEL[level] ?? { bg: '#546E7A', fg: '#FFFFFF', symbol: '?' };
return cellSecureIcon(pick.bg, pick.fg, pick.symbol);
}
/**
* @param {number | null | undefined} n
*/
function formatBytes(n) {
if (n == null || Number.isNaN(n)) {
return 'β';
}
if (n < 1024) {
return `${n} B`;
}
if (n < 1024 * 1024) {
return `${(n / 1024).toFixed(1)} KB`;
}
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
/**
* Registry publish time β relative phrasing (e.g. "18 days ago", "1 month ago").
* @param {string | null | undefined} isoOrDateString
*/
function formatLastUpdate(isoOrDateString) {
if (isoOrDateString == null || isoOrDateString === '') {
return 'β';
}
const then = new Date(isoOrDateString).getTime();
if (Number.isNaN(then)) {
return 'β';
}
const now = Date.now();
const diffSec = Math.floor((now - then) / 1000);
if (diffSec < 0) {
return new Date(isoOrDateString).toISOString().slice(0, 10);
}
if (diffSec < 60) {
return 'just now';
}
const mins = Math.floor(diffSec / 60);
if (mins < 60) {
return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
}
const hours = Math.floor(mins / 60);
if (hours < 24) {
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
}
const days = Math.floor(hours / 24);
if (days < 30) {
if (days === 0) {
return 'today';
}
if (days === 1) {
return '1 day ago';
}
return `${days} days ago`;
}
const months = Math.floor(days / 30);
if (months < 12) {
if (months === 1) {
return '1 month ago';
}
return `${months} months ago`;
}
const years = Math.floor(days / 365);
if (years === 1) {
return '1 year ago';
}
return `${years} years ago`;
}
/**
* PDF βGenerated:β line β local time, 12-hour clock.
* @param {Date} [date]
*/
function formatGeneratedAt(date = new Date()) {
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short',
});
}
/**
* @param {Array<{
* name: string,
* kind: string,
* installedVersion: string | null,
* latestVersion: string | null,
* updateType: string,
* lastPublished: string | null,
* unpackedSizeBytes: number | null,
* registryError?: string | null,
* securityLevel?: string,
* securityCount?: number,
* securityTooltip?: string
* }>} rows
*/
function printConsoleTable(rows) {
const table = new Table({
head: [
cellHeader('Package'),
cellHeader('Secure'),
cellHeader('Installed'),
cellHeader('Latest'),
cellHeader('Update'),
cellHeader('Size'),
cellHeader('Last Update'),
],
colAligns: ['left', 'center', 'center', 'center', 'center', 'right', 'left'],
style: {
head: [],
border: ['gray'],
},
wordWrap: true,
});
for (const r of rows) {
const lastUpdate = formatLastUpdate(r.lastPublished);
const registryNote = Boolean(r.registryError);
table.push([
cellPlain(r.name),
cellSecure(r),
cellInstalledVersion(r.installedVersion),
cellLatestVersion(r.latestVersion),
cellUpdateType(r.updateType, registryNote),
cellPlain(formatBytes(r.unpackedSizeBytes)),
cellPlain(lastUpdate),
]);
}
console.log(`${table.toString()}\u001b[0m`);
console.log(
chalk.gray('Secure (OSV): ') +
cellSecureIcon(SECURE_ICON.none.bg, SECURE_ICON.none.fg, SECURE_ICON.none.symbol) +
chalk.gray(' secure Β· ') +
cellSecureIcon(SECURE_ICON.moderate.bg, SECURE_ICON.moderate.fg, SECURE_ICON.moderate.symbol) +
chalk.gray('/') +
cellSecureIcon(SECURE_ICON.high.bg, SECURE_ICON.high.fg, SECURE_ICON.high.symbol) +
chalk.gray(' vulnerabilities (by severity) Β· ') +
cellSecureIcon(SECURE_ICON.unknown.bg, SECURE_ICON.unknown.fg, SECURE_ICON.unknown.symbol) +
chalk.gray(' check unavailable'),
);
}
const MAX_ADVISORY_CONSOLE = 15;
/**
* Same structured summary as the PDF (dependency health, security, recommendations, overall).
* @param {Array<{
* name: string,
* updateType: string,
* securityLevel?: string,
* }>} rows
*/
function printConsoleSummary(rows) {
const stats = summarizeDependencyRows(rows);
const narrative = buildStructuredSummary(stats);
console.log('');
console.log(chalk.white.bold('Summary'));
console.log('');
console.log(chalk.cyan.bold('Dependency Health Overview'));
for (const line of narrative.healthLines) {
console.log(`${chalk.gray(' β’')} ${chalk.white(line)}`);
}
console.log('');
console.log(chalk.cyan.bold('Security Status'));
for (const line of narrative.securityLines) {
console.log(`${chalk.gray(' β’')} ${chalk.white(line)}`);
}
if (stats.withAdvisory.length > 0) {
console.log('');
console.log(chalk.red.bold('Packages with vulnerabilities'));
for (const { name, level } of stats.withAdvisory.slice(0, MAX_ADVISORY_CONSOLE)) {
console.log(
`${chalk.gray(' β’')} ${chalk.white(`${name} (highest: ${capitalizeSeverity(level)})`)}`,
);
}
if (stats.withAdvisory.length > MAX_ADVISORY_CONSOLE) {
console.log(
chalk.gray(
` β’ β¦and ${stats.withAdvisory.length - MAX_ADVISORY_CONSOLE} more (see OSV.dev)`,
),
);
}
}
console.log('');
console.log(chalk.cyan.bold('Recommendation'));
for (const line of narrative.recommendationLines) {
console.log(`${chalk.gray(' β’')} ${chalk.white(line)}`);
}
console.log('');
const sym = narrative.overallIsPositive ? '\u2705 ' : '\u26a0 ';
const detailStyle = narrative.overallIsPositive ? chalk.green.bold : chalk.hex('#F59E0B').bold;
console.log(
chalk.white.bold(`${narrative.overallLabel}: `) +
detailStyle(`${sym}${narrative.overallDetail}`),
);
console.log('');
}
module.exports = {
printConsoleTable,
printConsoleSummary,
formatBytes,
formatLastUpdate,
formatGeneratedAt,
};
π Produces:
- Clean terminal table
- Colored badges
- Security indicators
π reportPdf.js (PDF Generator)
/**
* PDF report (PDFKit): white rows; Installed / Latest / Update use centered pill tags.
*/
const fs = require('fs');
const PDFDocument = require('pdfkit');
const {
PDF_TABLE,
formatUpdateColumnLabel,
UPDATE_TYPE,
BADGE_LABEL_FG,
} = require('./constants');
const { formatBytes, formatLastUpdate, formatGeneratedAt } = require('./reportConsole');
const {
summarizeDependencyRows,
buildStructuredSummary,
capitalizeSeverity,
} = require('./reportSummary');
/**
* Load a PNG/JPEG (or GIF) from an http(s) URL for the βsecureβ PDF icon.
* Returns null on failure (caller falls back to the vector check).
* @param {string} url
* @param {{ timeoutMs?: number, maxBytes?: number }} [limits]
* @returns {Promise<Buffer | null>}
*/
async function fetchSecureIconBuffer(url, limits = {}) {
const timeoutMs =
limits.timeoutMs ?? (Number(process.env.DEPENDENCY_REPORT_ICON_TIMEOUT_MS) || 8000);
const maxBytes =
limits.maxBytes ?? (Number(process.env.DEPENDENCY_REPORT_ICON_MAX_BYTES) || 524288);
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
clearTimeout(timer);
if (!res.ok) {
return null;
}
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length || buf.length > maxBytes) {
return null;
}
return buf;
} catch {
clearTimeout(timer);
return null;
}
}
/**
* Secure βcleanβ state: avoid Unicode β in Helvetica (thin / missing). Use a vector check
* in the PDF pill, or an embedded PNG when DEPENDENCY_REPORT_SECURE_ICON_URL loads.
*/
const GLYPH_INFO = 'i';
/** ASCII β always renders in Helvetica (avoid β for the same encoding issue). */
const GLYPH_ADVISORY = '!';
/**
* @param {*} doc
* @param {number} cx
* @param {number} cy
* @param {number} arm
* @param {string} strokeColor
*/
function drawPdfCheckStroke(doc, cx, cy, arm, strokeColor) {
doc.save();
doc.strokeColor(strokeColor).lineWidth(1.05).lineCap('round').lineJoin('round');
doc.moveTo(cx - arm * 0.78, cy + arm * 0.02);
doc.lineTo(cx - arm * 0.1, cy + arm * 0.72);
doc.lineTo(cx + arm * 0.98, cy - arm * 0.62);
doc.stroke();
doc.restore();
}
/**
* Secure column pills reuse the same pastel + label colors as the Update column
* (no custom shapes β matches Installed / Latest / Update visually).
*/
const SECURE_ISSUE_PILL = {
info: PDF_TABLE.updateBadge[UPDATE_TYPE.PATCH],
low: PDF_TABLE.updateBadge[UPDATE_TYPE.MINOR],
moderate: { bg: '#FED7AA', fg: BADGE_LABEL_FG },
high: PDF_TABLE.updateBadge[UPDATE_TYPE.MAJOR],
critical: { bg: '#FECACA', fg: BADGE_LABEL_FG },
};
/**
* @typedef {object} ReportRow
* @property {string} name
* @property {'dependencies'|'devDependencies'} kind
* @property {string | null} installedVersion
* @property {string | null} latestVersion
* @property {string} updateType
* @property {string | null} lastPublished
* @property {number | null} unpackedSizeBytes
* @property {string | null} [registryError]
* @property {string} [securityLevel]
* @property {number} [securityCount]
* @property {string} [securityTooltip]
*/
/**
* @param {string} updateType
* @returns {{ bg: string, fg: string } | null}
*/
function updateBadgeColors(updateType) {
return (
PDF_TABLE.updateBadge[updateType] ??
PDF_TABLE.updateBadge[UPDATE_TYPE.UNKNOWN]
);
}
const TAG_FONT = 6.5;
const PILL_PAD_X = 7;
const PILL_PAD_Y = 3;
const PILL_RADIUS = 5;
/** Dark green for βall secureβ summary line (matches Secure column tone). */
const SUMMARY_ALL_SECURE_FG = '#2D5A45';
/**
* @param {string} outPath
* @param {ReportRow[]} rows
* @param {{ title?: string, secureIconUrl?: string }} [opts]
*/
async function writePdfReport(outPath, rows, opts = {}) {
const iconUrl = String(opts.secureIconUrl || process.env.DEPENDENCY_REPORT_SECURE_ICON_URL || '').trim();
let secureIconBuffer = null;
if (iconUrl && /^https?:\/\//i.test(iconUrl)) {
secureIconBuffer = await fetchSecureIconBuffer(iconUrl);
if (!secureIconBuffer) {
console.warn(
'dependency-report: DEPENDENCY_REPORT_SECURE_ICON_URL could not be loaded; using built-in check icon in PDF.',
);
}
}
const title = opts.title || 'React Native Dependency Report';
const doc = new PDFDocument({
size: 'A4',
margin: 40,
info: {
Title: title,
Author: 'Dependency Report Script',
},
});
const stream = fs.createWriteStream(outPath);
doc.pipe(stream);
doc.fontSize(20).font('Helvetica-Bold').fillColor('#1a1a1a').text(title, { align: 'center' });
doc.moveDown(0.3);
doc
.fontSize(10)
.font('Helvetica')
.fillColor('#333333')
.text(`Generated: ${formatGeneratedAt()}`, { align: 'center' });
doc.moveDown(1.2);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x0 = doc.page.margins.left;
/* Secure needs enough width so βSecureβ stays on one line at 8pt (was wrapping as Secu/re). */
/* Secure column: clean rows use a vector check (or custom PNG); vulnerable rows uses ASCII β!β. */
const weights = [1.88, 0.55, 0.92, 0.92, 0.88, 0.72, 1.13];
const sum = weights.reduce((a, b) => a + b, 0);
const cols = weights.map((w) => (w / sum) * pageWidth);
const SECURE_COL = 1;
const INSTALLED_COL = 2;
const LATEST_COL = 3;
const UPDATE_COL = 4;
const SIZE_COL = 5;
const LAST_UPDATE_COL = 6;
const rowH = 22;
const headerH = 26;
let y = doc.y;
function ensureSpace(heightNeeded) {
if (y + heightNeeded > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
y = doc.page.margins.top;
}
}
function columnLeft(index) {
let left = x0;
for (let j = 0; j < index; j++) {
left += cols[j];
}
return left;
}
/**
* @param {number} x
* @param {number} width
* @param {string} text
* @param {{ size?: number, bold?: boolean, color?: string, align?: 'left'|'center'|'right' }} [options]
*/
function drawCellText(x, width, text, options = {}) {
const t = String(text ?? 'β');
const align = options.align || 'left';
doc.save();
doc
.font(options.bold ? 'Helvetica-Bold' : 'Helvetica')
.fontSize(options.size || 7)
.fillColor(options.color || PDF_TABLE.bodyText);
doc.text(t, x + 4, y + 5, {
width: width - 8,
ellipsis: true,
lineBreak: false,
align,
});
doc.restore();
}
/**
* Centered rounded pill (Installed, Latest, Update columns).
* @param {number} ux
* @param {number} colW
* @param {string} label
* @param {{ bg: string, fg: string }} style
* @param {{ bold?: boolean, fontSize?: number }} [fontOpts]
*/
function drawPill(ux, colW, label, style, fontOpts = {}) {
const bold = fontOpts.bold !== false;
const fs = typeof fontOpts.fontSize === 'number' ? fontOpts.fontSize : TAG_FONT;
doc.save();
const innerPad = 4;
const maxTagW = Math.max(0, colW - innerPad * 2);
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(fs);
const textW = doc.widthOfString(label);
let tagW = Math.min(textW + PILL_PAD_X * 2, maxTagW);
if (tagW < fs + PILL_PAD_X) {
tagW = Math.min(maxTagW, fs + PILL_PAD_X * 2);
}
const tagH = fs + PILL_PAD_Y * 2;
const tagX = ux + (colW - tagW) / 2;
const tagY = y + (rowH - tagH) / 2;
doc.roundedRect(tagX, tagY, tagW, tagH, PILL_RADIUS).fill(style.bg);
doc.fillColor(style.fg);
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(fs);
const lineH = doc.currentLineHeight(true);
const textY = tagY + (tagH - lineH) / 2 + lineH * 0.08;
doc.text(label, tagX, textY, {
width: tagW,
align: 'center',
lineBreak: false,
ellipsis: true,
});
doc.restore();
}
/**
* Mint βsecureβ pill: optional remote PNG/JPEG in `iconBuffer`, else vector checkmark.
* @param {number} ux
* @param {number} colW
* @param {Buffer | null} [iconBuffer]
*/
function drawSecureCleanPill(ux, colW, iconBuffer) {
const style = PDF_TABLE.latestPill;
const fs = 7.2;
const innerPad = 4;
const maxTagW = Math.max(0, colW - innerPad * 2);
let tagW = Math.min(fs + PILL_PAD_X * 2, maxTagW);
const tagH = fs + PILL_PAD_Y * 2;
const tagX = ux + (colW - tagW) / 2;
const tagY = y + (rowH - tagH) / 2;
doc.save();
doc.roundedRect(tagX, tagY, tagW, tagH, PILL_RADIUS).fill(style.bg);
const cx = tagX + tagW / 2;
const cy = tagY + tagH / 2 + 0.35;
const arm = Math.min(tagW, tagH) * 0.3;
if (iconBuffer && Buffer.isBuffer(iconBuffer) && iconBuffer.length > 0) {
try {
const maxSide = Math.min(tagW - 2, tagH - 2, 11);
const ix = tagX + (tagW - maxSide) / 2;
const iy = tagY + (tagH - maxSide) / 2;
doc.image(iconBuffer, ix, iy, { width: maxSide, height: maxSide, fit: [maxSide, maxSide] });
} catch {
drawPdfCheckStroke(doc, cx, cy, arm, style.fg);
}
} else {
drawPdfCheckStroke(doc, cx, cy, arm, style.fg);
}
doc.restore();
}
// Header row
ensureSpace(headerH);
doc.save();
doc.rect(x0, y, pageWidth, headerH).fill(PDF_TABLE.headerBg);
doc.restore();
let cx = x0;
const headers = [
'Package',
'Secure',
'Installed',
'Latest',
'Update',
'Size',
'Last Update',
];
headers.forEach((h, i) => {
doc.save();
doc.font('Helvetica-Bold').fontSize(8).fillColor(PDF_TABLE.headerText);
let hAlign = 'left';
if (i === SECURE_COL || i === INSTALLED_COL || i === LATEST_COL || i === UPDATE_COL) {
hAlign = 'center';
}
if (i === SIZE_COL) {
hAlign = 'right';
}
doc.text(h, cx + 4, y + 8, {
width: cols[i] - 8,
align: hAlign,
lineBreak: false,
ellipsis: true,
});
doc.restore();
cx += cols[i];
});
y += headerH;
for (const r of rows) {
ensureSpace(rowH);
doc.save();
doc.rect(x0, y, pageWidth, rowH).fill(PDF_TABLE.rowBg);
doc.strokeColor(PDF_TABLE.grid).lineWidth(0.25);
doc.rect(x0, y, pageWidth, rowH).stroke();
doc.restore();
const lastUpdate = formatLastUpdate(r.lastPublished);
const registryNote = Boolean(r.registryError);
const updateLabel = formatUpdateColumnLabel(r.updateType, registryNote);
const badge = updateBadgeColors(r.updateType);
drawCellText(columnLeft(0), cols[0], r.name, { align: 'left' });
const secUx = columnLeft(SECURE_COL);
const secW = cols[SECURE_COL];
const secLevel = r.securityLevel ?? 'unknown';
if (secLevel === 'none') {
drawSecureCleanPill(secUx, secW, secureIconBuffer);
} else if (secLevel === 'unknown') {
drawCellText(secUx, secW, '\u2014', { align: 'center', size: 9, color: '#94A3B8' });
} else if (secLevel === 'info') {
drawPill(secUx, secW, GLYPH_INFO, SECURE_ISSUE_PILL.info, { fontSize: 7.2 });
} else {
const issueStyle = SECURE_ISSUE_PILL[secLevel] ?? SECURE_ISSUE_PILL.high;
drawPill(secUx, secW, GLYPH_ADVISORY, issueStyle, { fontSize: 7.5 });
}
const installedText = r.installedVersion ?? 'β';
const latestText = r.latestVersion ?? 'β';
if (installedText === 'β') {
drawCellText(columnLeft(INSTALLED_COL), cols[INSTALLED_COL], 'β', {
align: 'center',
});
} else {
drawPill(columnLeft(INSTALLED_COL), cols[INSTALLED_COL], installedText, PDF_TABLE.installedPill);
}
if (latestText === 'β') {
drawCellText(columnLeft(LATEST_COL), cols[LATEST_COL], 'β', { align: 'center' });
} else {
drawPill(columnLeft(LATEST_COL), cols[LATEST_COL], latestText, PDF_TABLE.latestPill);
}
drawCellText(columnLeft(SIZE_COL), cols[SIZE_COL], formatBytes(r.unpackedSizeBytes), {
align: 'right',
});
drawCellText(columnLeft(LAST_UPDATE_COL), cols[LAST_UPDATE_COL], lastUpdate, { align: 'left' });
drawPill(columnLeft(UPDATE_COL), cols[UPDATE_COL], updateLabel, badge);
y += rowH;
}
const stats = summarizeDependencyRows(rows);
const narrative = buildStructuredSummary(stats);
const summaryReserve =
300 +
Math.min(160, stats.withAdvisory.length * 12) +
narrative.recommendationLines.length * 10;
const needsRegistryFootnote = rows.some((row) => row.registryError);
doc.moveDown(0.75);
y = doc.y;
/* Room for registry note + Summary (counts + secure list). */
ensureSpace((needsRegistryFootnote ? 72 : 0) + summaryReserve);
if (needsRegistryFootnote) {
const footnoteRest =
'If an update tag shows a trailing asterisk (*), the latest version could not be read from the npm registry. ' +
'Typical reasons are git, file, link, or workspace dependencies, or a failed registry request.';
doc
.font('Helvetica-Bold')
.fontSize(8)
.fillColor('#555555')
.text('Note: ', x0, y, { continued: true, lineBreak: false });
doc
.font('Helvetica')
.fillColor('#666666')
.text(footnoteRest, {
width: pageWidth,
lineGap: 2,
align: 'left',
});
doc.moveDown(0.55);
y = doc.y;
}
doc.moveDown(0.5);
ensureSpace(Math.min(380, 260 + stats.withAdvisory.length * 11));
const bulletX = x0 + 6;
const bulletW = pageWidth - 12;
doc.font('Helvetica-Bold').fontSize(10).fillColor('#1a1a1a').text('Summary', x0, doc.y, {
width: pageWidth,
lineGap: 2,
});
doc.moveDown(0.35);
/**
* @param {string} sectionTitle
* @param {string[]} lines
*/
function writeSummarySection(sectionTitle, lines) {
doc.font('Helvetica-Bold').fontSize(8.5).fillColor('#1a1a1a').text(sectionTitle, x0, doc.y, {
width: pageWidth,
lineGap: 2,
});
doc.moveDown(0.14);
doc.font('Helvetica').fontSize(8).fillColor('#374151');
for (const line of lines) {
doc.text(`β’ ${line}`, bulletX, doc.y, {
width: bulletW,
lineGap: 2.5,
align: 'left',
});
}
doc.moveDown(0.32);
}
writeSummarySection('Dependency Health Overview', narrative.healthLines);
writeSummarySection('Security Status', narrative.securityLines);
if (stats.withAdvisory.length > 0) {
doc.font('Helvetica-Bold').fontSize(8).fillColor('#991B1B').text('Packages with vulnerabilities', x0, doc.y, {
width: pageWidth,
lineGap: 2,
});
doc.moveDown(0.1);
doc.font('Helvetica').fontSize(8).fillColor('#444444');
const maxList = 15;
const list = stats.withAdvisory.slice(0, maxList);
for (const { name, level } of list) {
doc.text(`β’ ${name} (highest: ${capitalizeSeverity(level)})`, bulletX + 2, doc.y, {
width: bulletW - 2,
lineGap: 1.5,
align: 'left',
});
}
if (stats.withAdvisory.length > maxList) {
doc.text(
`β’ ... and ${stats.withAdvisory.length - maxList} more (see OSV.dev)`,
bulletX + 2,
doc.y,
{ width: bulletW - 2, lineGap: 2 },
);
}
doc.moveDown(0.28);
}
writeSummarySection('Recommendation', narrative.recommendationLines);
const overallColor = narrative.overallIsPositive ? SUMMARY_ALL_SECURE_FG : '#B45309';
const yOverall = doc.y;
const overallFs = 9;
const overallGap = 4;
doc.font('Helvetica-Bold').fontSize(overallFs);
const overallLabelStr = `${narrative.overallLabel}: `;
const labelW = doc.widthOfString(overallLabelStr);
doc.fillColor('#1a1a1a').text(overallLabelStr, x0, yOverall, { lineBreak: false });
const detailX = x0 + labelW + overallGap;
doc.font('Helvetica-Bold').fontSize(overallFs).fillColor(overallColor);
doc.text(narrative.overallDetail, detailX, yOverall, { lineBreak: false });
doc.end();
return new Promise((resolve, reject) => {
stream.on('finish', () => resolve(outPath));
stream.on('error', reject);
});
}
module.exports = { writePdfReport };
π Generates:
- Styled report
- Visual badges
- Summary insights
π reportSummary.js (Insight Engine)
/**
* Shared dependency report summary (PDF + terminal): counts, narrative, recommendations.
*/
const { UPDATE_TYPE } = require('./constants');
/**
* @param {Array<{
* name: string,
* updateType: string,
* securityLevel?: string,
* }>} rows
*/
function summarizeDependencyRows(rows) {
const byUpdate = {
minor: 0,
major: 0,
patch: 0,
latest: 0,
unknown: 0,
prerelease: 0,
};
for (const r of rows) {
switch (r.updateType) {
case UPDATE_TYPE.MINOR:
byUpdate.minor++;
break;
case UPDATE_TYPE.MAJOR:
byUpdate.major++;
break;
case UPDATE_TYPE.PATCH:
byUpdate.patch++;
break;
case UPDATE_TYPE.UP_TO_DATE:
byUpdate.latest++;
break;
case UPDATE_TYPE.UNKNOWN:
byUpdate.unknown++;
break;
case UPDATE_TYPE.PRERELEASE:
byUpdate.prerelease++;
break;
default:
byUpdate.unknown++;
}
}
/** @type {{ name: string, level: string }[]} */
const withAdvisory = [];
let nClean = 0;
let nAuditUnknown = 0;
for (const r of rows) {
const s = r.securityLevel ?? 'unknown';
if (s === 'none') {
nClean++;
} else if (s === 'unknown') {
nAuditUnknown++;
} else if (s === 'info' || s === 'low' || s === 'moderate' || s === 'high' || s === 'critical') {
withAdvisory.push({ name: r.name, level: s });
}
}
const allAuditClean = rows.length > 0 && nClean === rows.length && nAuditUnknown === 0;
const allAuditUnknown = rows.length > 0 && nAuditUnknown === rows.length;
return {
total: rows.length,
byUpdate,
withAdvisory,
nClean,
nAuditUnknown,
allAuditClean,
allAuditUnknown,
};
}
function capitalizeSeverity(level) {
return level.charAt(0).toUpperCase() + level.slice(1);
}
/** @param {number} n @param {string} singular @param {string} plural */
function nounCount(n, singular, plural) {
return `${n} ${n === 1 ? singular : plural}`;
}
/**
* @param {{
* total: number,
* byUpdate: object,
* withAdvisory: { name: string, level: string }[],
* nClean: number,
* nAuditUnknown: number,
* allAuditClean: boolean,
* allAuditUnknown: boolean,
* }} stats
*/
function buildStructuredSummary(stats) {
const { total, byUpdate, withAdvisory, nClean, nAuditUnknown, allAuditClean, allAuditUnknown } = stats;
const { minor, major, patch, latest, unknown, prerelease } = byUpdate;
const healthLines = [
`${total} package${total === 1 ? '' : 's'} analyzed`,
`${minor} minor update${minor === 1 ? '' : 's'} available (safe to upgrade)`,
`${major} major update${major === 1 ? '' : 's'} available (requires review)`,
`${patch} patch update${patch === 1 ? '' : 's'} available (low-risk improvements)`,
`${latest} package${latest === 1 ? '' : 's'} ${latest === 1 ? 'is' : 'are'} up to date`,
`${unknown} package${unknown === 1 ? '' : 's'} ${unknown === 1 ? 'has' : 'have'} unknown status`,
];
if (prerelease > 0) {
healthLines.push(
`${prerelease} package${prerelease === 1 ? '' : 's'} ${prerelease === 1 ? 'has' : 'have'} prerelease-related semver`,
);
}
/** @type {string[]} */
const securityLines = [];
if (total === 0) {
securityLines.push('No packages were included in this report.');
} else if (withAdvisory.length > 0) {
securityLines.push(
`${nClean} package${nClean === 1 ? '' : 's'} ${nClean === 1 ? 'has' : 'have'} no known issues in OSV for queried versions`,
);
securityLines.push(
`${withAdvisory.length} package${withAdvisory.length === 1 ? '' : 's'} ${withAdvisory.length === 1 ? 'has' : 'have'} known vulnerabilities (OSV.dev)`,
);
securityLines.push('Review the table and OSV records (https://osv.dev) for remediation steps.');
} else if (allAuditClean) {
securityLines.push(`All ${total} package${total === 1 ? '' : 's'} are secure`);
securityLines.push('No vulnerabilities detected via OSV for queried versions');
securityLines.push('No packages with known vulnerabilities');
} else if (allAuditUnknown) {
securityLines.push('OSV did not return usable results for this run');
securityLines.push('Secure column shows - until OSV succeeds (network, lockfile, or API errors)');
securityLines.push('Ensure package-lock.json exists or deps are installed, then re-run the report');
} else {
securityLines.push(
`No known OSV vulnerabilities for ${nClean} package${nClean === 1 ? '' : 's'} where the check applied`,
);
securityLines.push(
`${nAuditUnknown} package${nAuditUnknown === 1 ? '' : 's'} could not be assessed (OSV unavailable)`,
);
}
/** @type {string[]} */
const recommendationLines = [];
if (total === 0) {
recommendationLines.push('Add dependencies to package.json to populate this report.');
} else {
if (major > 0) {
recommendationLines.push(
`Prioritize upgrading ${nounCount(major, 'major dependency', 'major dependencies')} with caution`,
);
}
if (minor > 0 || patch > 0) {
const bits = [];
if (minor > 0) {
bits.push(`${minor} minor`);
}
if (patch > 0) {
bits.push(`${patch} patch`);
}
recommendationLines.push(
`Apply ${bits.join(' and ')} update${minor + patch === 1 ? '' : 's'} to keep the project current`,
);
}
if (unknown > 0) {
recommendationLines.push(`Investigate ${nounCount(unknown, 'package', 'packages')} with unknown status`);
}
if (withAdvisory.length > 0) {
recommendationLines.push(
`Remediate ${nounCount(withAdvisory.length, 'package', 'packages')} with known security vulnerabilities`,
);
}
if (recommendationLines.length === 0) {
recommendationLines.push('No semver upgrades flagged; continue monitoring for new releases.');
}
}
let overallLabel = 'Overall Status';
let overallDetail = '';
let overallIsPositive = true;
if (total === 0) {
overallDetail = 'No data';
overallIsPositive = false;
} else if (withAdvisory.length > 0) {
overallDetail = 'Needs attention - security vulnerabilities present';
overallIsPositive = false;
} else if (allAuditUnknown) {
overallDetail = 'Security check unavailable - confirm OSV / lockfile';
overallIsPositive = false;
} else if (major > 0 || minor > 0 || patch > 0 || unknown > 0) {
overallDetail = 'Healthy with pending upgrades';
overallIsPositive = true;
} else {
overallDetail = 'Up to date';
overallIsPositive = true;
}
return {
healthLines,
securityLines,
recommendationLines,
overallLabel,
overallDetail,
overallIsPositive,
};
}
module.exports = {
summarizeDependencyRows,
buildStructuredSummary,
capitalizeSeverity,
};
π Converts data β decisions.
π constants.js (Shared Config)
/**
* Shared constants for dependency reporting (colors, sort order, labels).
*/
/** @enum {string} */
exports.UPDATE_TYPE = {
MAJOR: 'Major',
MINOR: 'Minor',
PATCH: 'Patch',
UP_TO_DATE: 'Up to date',
PRERELEASE: 'Prerelease',
UNKNOWN: 'Unknown',
};
/** Sort priority: lower = earlier in list when sorting by update type. */
exports.UPDATE_TYPE_SORT_ORDER = {
[exports.UPDATE_TYPE.MAJOR]: 0,
[exports.UPDATE_TYPE.MINOR]: 1,
[exports.UPDATE_TYPE.PATCH]: 2,
[exports.UPDATE_TYPE.PRERELEASE]: 3,
[exports.UPDATE_TYPE.UP_TO_DATE]: 4,
[exports.UPDATE_TYPE.UNKNOWN]: 5,
};
/** Soft βlight blackβ for badge text (calmer than #000, still readable on pastels). */
exports.BADGE_LABEL_FG = '#4B5563';
/**
* PDF / shared pill colors (hex). Light, calm fills with darker text for contrast.
*/
exports.PDF_TABLE = {
rowBg: '#FFFFFF',
grid: '#DDE1E6',
headerBg: '#2c3e50',
headerText: '#FFFFFF',
bodyText: '#1a1a1a',
/** Version columns β dark text on light fills */
installedPill: { bg: '#F3F4F6', fg: exports.BADGE_LABEL_FG },
latestPill: { bg: '#D1FAE5', fg: exports.BADGE_LABEL_FG },
/** Update status β soft pastels */
updateBadge: {
[exports.UPDATE_TYPE.MAJOR]: { bg: '#FEE2E2', fg: exports.BADGE_LABEL_FG },
[exports.UPDATE_TYPE.MINOR]: { bg: '#FEF3C7', fg: exports.BADGE_LABEL_FG },
[exports.UPDATE_TYPE.PATCH]: { bg: '#DBEAFE', fg: exports.BADGE_LABEL_FG },
[exports.UPDATE_TYPE.UP_TO_DATE]: { bg: '#D1FAE5', fg: exports.BADGE_LABEL_FG },
[exports.UPDATE_TYPE.UNKNOWN]: { bg: '#F3F4F6', fg: exports.BADGE_LABEL_FG },
[exports.UPDATE_TYPE.PRERELEASE]: { bg: '#EDE9FE', fg: exports.BADGE_LABEL_FG },
},
};
/** @param {string} hex #RRGGBB */
exports.hexToRgb = function hexToRgb(hex) {
const h = hex.replace('#', '');
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
];
};
/**
* @param {string} updateType
* @param {boolean} registryNote
*/
exports.formatUpdateColumnLabel = function formatUpdateColumnLabel(updateType, registryNote) {
const star = registryNote ? '*' : '';
let label;
if (updateType === exports.UPDATE_TYPE.UP_TO_DATE) {
label = 'LATEST';
} else if (updateType === exports.UPDATE_TYPE.UNKNOWN) {
label = 'Unknown';
} else {
label = String(updateType).toUpperCase();
}
return `${label}${star}`;
};
π Central source of truth.
π badges.js (UI Styling Engine)
/**
* npm / GitHub Primerβstyle badge system: flat chips, 1px border, tight padding,
* monospace for semver (like registry UI), sans-semibold for status labels.
*/
const { UPDATE_TYPE, BADGE_LABEL_FG } = require('./constants');
/**
* Layout tuned for PDF points (~11β12px equivalent at table scale).
*/
exports.NPM_BADGE_LAYOUT = {
/** ~4px radius β flat chip, not a pill */
radius: 3,
borderWidth: 0.55,
padX: 6.75,
padY: 3.75,
/** Courier = registry-style version strings */
fontVersion: 7,
fontStatus: 7.35,
};
/**
* @typedef {{ bg: string, fg: string, border: string }} NpmBadgeStyle
*/
/** Installed / Latest β version column chips (Primer default + npm blue βlatestβ) */
exports.versionBadges = {
installed: {
bg: '#FFFFFF',
fg: BADGE_LABEL_FG,
border: '#D0D7DE',
},
latest: {
bg: '#DDF4FF',
fg: BADGE_LABEL_FG,
border: '#79C0FF',
},
};
/** Update column β Primer Labelβstyle danger / attention / accent / success / neutral */
exports.statusBadges = {
[UPDATE_TYPE.MAJOR]: {
bg: '#FFEBE9',
fg: BADGE_LABEL_FG,
border: '#FF8182',
},
[UPDATE_TYPE.MINOR]: {
bg: '#FFF8C5',
fg: BADGE_LABEL_FG,
border: '#EAC54F',
},
[UPDATE_TYPE.PATCH]: {
bg: '#DDF4FF',
fg: BADGE_LABEL_FG,
border: '#79C0FF',
},
[UPDATE_TYPE.UP_TO_DATE]: {
bg: '#DAFBE1',
fg: BADGE_LABEL_FG,
border: '#4AC26B',
},
[UPDATE_TYPE.UNKNOWN]: {
bg: '#F6F8FA',
fg: BADGE_LABEL_FG,
border: '#D0D7DE',
},
[UPDATE_TYPE.PRERELEASE]: {
bg: '#FBEFFF',
fg: BADGE_LABEL_FG,
border: '#D8B9FF',
},
};
/**
* @param {string} updateType
* @returns {NpmBadgeStyle}
*/
exports.getStatusBadgeStyle = function getStatusBadgeStyle(updateType) {
return exports.statusBadges[updateType] ?? exports.statusBadges[UPDATE_TYPE.UNKNOWN];
};
/**
* @param {'version' | 'status'} kind
*/
function badgeFonts(kind) {
if (kind === 'version') {
return { family: 'Courier-Bold', size: exports.NPM_BADGE_LAYOUT.fontVersion };
}
return { family: 'Helvetica-Bold', size: exports.NPM_BADGE_LAYOUT.fontStatus };
}
/**
* Draw a bordered, filled rounded rect + centered label (npm chip).
* @param {object} doc PDFKit document
* @param {number} ux column left
* @param {number} colW column width
* @param {number} rowTop
* @param {number} rowH
* @param {string} label
* @param {NpmBadgeStyle} style
* @param {'version' | 'status'} kind
*/
exports.drawNpmBadge = function drawNpmBadge(
doc,
ux,
colW,
rowTop,
rowH,
label,
style,
kind,
) {
const L = exports.NPM_BADGE_LAYOUT;
const innerPad = 2.5;
const maxTagW = Math.max(0, colW - innerPad * 2);
const { family, size } = badgeFonts(kind);
doc.font(family).fontSize(size);
const textW = doc.widthOfString(label);
let tagW = Math.min(textW + L.padX * 2, maxTagW);
const minW = size + L.padX * 1.25;
if (tagW < minW) {
tagW = Math.min(maxTagW, minW);
}
const tagH = size + L.padY * 2;
const tagX = ux + (colW - tagW) / 2;
const tagY = rowTop + (rowH - tagH) / 2;
doc.save();
doc.roundedRect(tagX, tagY, tagW, tagH, L.radius).fill(style.bg);
doc
.roundedRect(tagX, tagY, tagW, tagH, L.radius)
.lineWidth(L.borderWidth)
.strokeColor(style.border)
.stroke();
doc.fillColor(style.fg).font(family).fontSize(size);
const lineH = doc.currentLineHeight(true);
const textY = tagY + (tagH - lineH) / 2 + lineH * 0.065;
doc.text(label, tagX, textY, {
width: tagW,
align: 'center',
lineBreak: false,
ellipsis: true,
});
doc.restore();
};
π Controls visual consistency across CLI & PDF.
π package.json (Script Entry)
"scripts": {
"report:deps": "node src/config/scripts/dependency-report/generate-report.js"
}
π Run everything with:
yarn report:deps
π Output
Terminal View
PDF View
β οΈ Honest Reality Check
- npm registry calls can be slow
- OSV dependency introduces risk
- No CI/CD integration yet
- Transitive deps not fully visible
π References
π₯ Final Thought
Dependency management is no longer optional.
Itβs a security layer.
If you're not tracking it properly β you're exposed.


Top comments (0)