Skip to content

Instantly share code, notes, and snippets.

@damusix
Last active September 10, 2025 14:42
Show Gist options
  • Save damusix/b46c5243bda2962a78f0f3eb6a80a1fb to your computer and use it in GitHub Desktop.
Save damusix/b46c5243bda2962a78f0f3eb6a80a1fb to your computer and use it in GitHub Desktop.
A small CLI utility to search a repository's lockfiles and package.json files for one or more npm packages using dynamic regex matching. This is intentionally lightweight and does not attempt to parse YAML/JSON files.

Dependency Scanner

npm version License: MIT

A tool for discovering dependencies recursively across package.json and common lock files, useful for finding vulnerabilities and package versions deeply nested in your projects.

Features

  • Search pnpm-lock.yaml, yarn.lock, package-lock.json, npm-shrinkwrap.json, and package.json files recursively
  • Accept packages via a -p comma-separated argument or via file/stdin (-f file, -f -, --stdin)
  • Validate package names and basic semver versions; invalid lines are warned and ignored
  • Works with piped input (e.g., clipboard via pbpaste) and can be run from any root path with -r
  • Lightweight implementation using dynamic regex matching
  • No external dependencies

Installation

Global Installation

Install globally using npm:

npm install -g @damusix/dep-scanner

Or using pnpm:

pnpm add -g @damusix/dep-scanner

Or using yarn:

yarn global add @damusix/dep-scanner

Run without Installation

You can also run dep-scanner directly without installing it globally using npx:

npx @damusix/dep-scanner -p express,lodash

Usage

After installation, the dep-scanner command will be available globally.

Scan packages specified directly:

dep-scanner -p [email protected],lodash -r /path/to/repo

Scan packages from a file:

dep-scanner -f packages.txt -r /path/to/repo

Pipe clipboard or other stream into the scanner:

pbpaste | dep-scanner -f - -r /path/to/repo
pbpaste | dep-scanner --stdin -r /path/to/repo

Auto-detect piped stdin (no -p or -f):

pbpaste | dep-scanner -r /path/to/repo

Scan current directory:

dep-scanner -p express,react

Command Options

  • -p: Comma-separated packages (version optional). Example: -p [email protected],@scope/[email protected],pkg2
  • -f: File with newline-separated entries, format: package=version or package@version (version optional)
  • -r: Root path to start searching from (defaults to current working directory)
  • -h: Show help message

Examples

Finding a specific vulnerable package version:

dep-scanner -p [email protected] -r ~/projects/myapp

Checking multiple packages from a file:

Create a file vulnerable-packages.txt:

[email protected]
[email protected]
[email protected]

Then run:

dep-scanner -f vulnerable-packages.txt -r ~/projects

Quick clipboard scan (macOS):

Copy a list of packages to clipboard, then:

pbpaste | dep-scanner --stdin

Notes

  • This tool uses dynamic regex matches and may produce false positives for unusual file formats
  • It's intended for quick repository searches, not authoritative dependency resolution
  • The tool searches through lock files and package.json without parsing them as JSON/YAML

Author

Danilo Alonso [email protected]

License

MIT

Repository

GitHub Gist

#!/usr/bin/env node
// dep-scanner
// Searches pnpm-lock.yaml, yarn.lock, package-lock.json and package.json files for NPM packages
// Usage: dep-scanner -p [email protected],pkg2 -f somefile.txt
const fs = require('fs');
const path = require('path');
const readline = require('readline');
// --- Helpers ---------------------------------------------------------------
function usage() {
console.log('Usage: dep-scanner [-p pkg@version,...] [-f file] [-r root]');
console.log(' -p comma-separated packages (version optional). Example: -p [email protected],@scope/[email protected],pkg2');
console.log(' -f file with newline-separated entries, format: package=version or package@version (version optional)');
console.log(' -r root path to start searching from (defaults to current working directory)');
console.log('');
console.log('Examples:');
console.log(' # scan packages listed in a file');
console.log(' node tmp/dep-scanner -f my-packages.txt -r /path/to/repo');
console.log('');
console.log(' # pipe clipboard (macOS pbpaste) or any stream into the scanner (use -f - or --stdin)');
console.log(' pbpaste | node tmp/dep-scanner -f - -r /path/to/repo');
console.log(' pbpaste | node tmp/dep-scanner --stdin -r /path/to/repo');
console.log('');
console.log(' # auto-detect piped stdin (no -p/-f)');
console.log(' pbpaste | node tmp/dep-scanner -r /path/to/repo');
console.log(' -h show help');
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parsePkgSpecFromArg(spec) {
// Handle scoped packages correctly. If spec starts with '@', a version (optional) is
// after the last '@'. Examples:
// pkg -> name=pkg, version=null
// [email protected] -> name=pkg, version=1.0.0
// @scope/pkg -> name=@scope/pkg, version=null
// @scope/[email protected] -> name=@scope/pkg, version=1.2.3
if (!spec || typeof spec !== 'string') return null;
const lastAt = spec.lastIndexOf('@');
if (spec[0] === '@') {
// scoped name; need to find second @ (if any). If only one @ at position 0 it's scoped no version.
if (lastAt === 0) return { name: spec, version: null };
}
if (lastAt > 0) {
const name = spec.slice(0, lastAt);
const version = spec.slice(lastAt + 1);
return { name: name.trim(), version: version.trim() || null };
}
return { name: spec.trim(), version: null };
}
function parsePkgSpecFromFileLine(line) {
// Accept formats like:
// name=1.2.3
// [email protected]
// name
// Comments ("#") are ignored. Blank lines return null.
const cleaned = line.split('#')[0].trim(); // strip comments
if (!cleaned) return null;
// If it contains '=', use that as delimiter
if (cleaned.includes('=')) {
const parts = cleaned.split('=');
const name = parts[0].trim();
const version = parts.slice(1).join('=').trim();
return { name, version: version || null };
}
// If it contains a version via @ (handle scoped packages correctly), delegate to parsePkgSpecFromArg
// e.g. [email protected] or @scope/[email protected]
const atIndex = cleaned.lastIndexOf('@');
if (atIndex > 0) {
// not a leading @ of a scoped package without version
return parsePkgSpecFromArg(cleaned);
}
// fallback: just the name
return { name: cleaned, version: null };
}
const pkgNameRx = /^(@[a-z0-9][a-z0-9-_.]*\/)?[a-z0-9][a-z0-9-_.]*$/i;
function isValidPackageName(name) {
// Basic validation for npm package names and scoped names.
// Allow letters, numbers, underscores, dots, dashes; scoped: @scope/name
if (!name || typeof name !== 'string') return false;
// no trailing slash
if (name.endsWith('/')) return false;
return pkgNameRx.test(name);
}
const versionRx = /^\d+\.\d+\.\d+([\-+][A-Za-z0-9-.]+)?$/;
function isValidVersion(version) {
if (!version) return true; // version optional
// Basic semver strict check: x.y.z (allow pre/build metadata)
return versionRx.test(version);
}
// --- File discovery -------------------------------------------------------
const skip = ['node_modules', '.git', 'tmp', 'logs', 'dist', 'build'];
function shouldSkipDir(name) {
return skip.includes(name);
}
function findTargetFiles(startDir) {
const results = [];
const stack = [startDir];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (err) {
continue;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (!shouldSkipDir(ent.name)) stack.push(full);
continue;
}
if (!ent.isFile()) continue;
// include common lockfiles from npm/yarn/pnpm and package.json files
if (['pnpm-lock.yaml', 'yarn.lock', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(ent.name)) {
results.push(full);
}
}
}
return results;
}
// --- Searching logic ------------------------------------------------------
function buildRegexesForPackage(pkg) {
const nameEsc = escapeRegExp(pkg.name);
const res = { name: pkg.name, version: pkg.version, patterns: [] };
if (pkg.version) {
const vEsc = escapeRegExp(pkg.version);
// common patterns: name@version, name=version, "name": "version", name": "^version, name": "~version
res.patterns.push(new RegExp(nameEsc + '\\s*@\\s*' + vEsc, 'i'));
res.patterns.push(new RegExp(nameEsc + '\\s*[=:]\\s*"?' + vEsc + '"?', 'i'));
res.patterns.push(new RegExp('\"' + nameEsc + '\"\\s*:\\s*"?' + vEsc + '"?', 'i'));
res.patterns.push(new RegExp(nameEsc + '[^\n]{0,60}' + vEsc, 'i'));
} else {
// version not provided; search for occurrences of name possibly followed by @version
res.patterns.push(new RegExp(nameEsc + '\\s*@\\d+\\.\\d+\\.\\d+', 'i'));
res.patterns.push(new RegExp('\"' + nameEsc + '\"', 'i')); // package.json key
res.patterns.push(new RegExp(nameEsc + '[\\/\\-]?', 'i'));
}
return res;
}
function extractVersionFromSnippet(snippet, pkgName) {
// Try to extract a semver after an @ or after :/="=
const rxAt = new RegExp(escapeRegExp(pkgName) + '\\s*@\\s*(\\d+\\.\\d+\\.\\d+[A-Za-z0-9-_.+]*)', 'i');
const m1 = snippet.match(rxAt);
if (m1 && m1[1]) return m1[1];
const rxVal = /[:=]\s*"?(\d+\.\d+\.\d+[A-Za-z0-9-_.+]*)"?/;
const m2 = snippet.match(rxVal);
if (m2 && m2[1]) return m2[1];
// last resort, search any semver-like token nearby
const rxAny = /(\d+\.\d+\.\d+[A-Za-z0-9-_.+]*)/;
const m3 = snippet.match(rxAny);
if (m3 && m3[1]) return m3[1];
return null;
}
function searchFileForPackage(filePath, pkgEntry) {
const txt = fs.readFileSync(filePath, 'utf8');
const lines = txt.split(/\r?\n/);
const hits = [];
const patterns = pkgEntry.patterns;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const p of patterns) {
if (p.test(line)) {
// capture a small snippet: line plus +/- 2 neighbors
const start = Math.max(0, i - 2);
const end = Math.min(lines.length - 1, i + 2);
const snippet = lines.slice(start, end + 1).join('\n');
const foundVersion = pkgEntry.version || extractVersionFromSnippet(snippet, pkgEntry.name);
hits.push({ line: i + 1, snippet: snippet.trim(), version: foundVersion });
break; // don't double-count same line for this package
}
}
}
return hits;
}
// --- CLI ------------------------------------------------------------------
async function main() {
const argv = process.argv.slice(2);
if (!argv.length) {
usage();
process.exit(1);
}
let pArg = null;
let fArg = null;
let rArg = null;
let stdinFlag = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-h' || a === '--help') {
usage();
process.exit(0);
}
if (a === '-p') {
pArg = argv[++i];
continue;
}
if (a === '-f') {
fArg = argv[++i];
continue;
}
if (a === '-r' || a === '--root') {
rArg = argv[++i];
continue;
}
if (a === '--stdin') {
stdinFlag = true;
continue;
}
// allow combined form -p=...
if (a.startsWith('-p=')) pArg = a.slice(3);
if (a.startsWith('-f=')) fArg = a.slice(3);
if (a.startsWith('-r=')) rArg = a.slice(3);
if (a === '-f' && argv[i+1] === '-') {
// allow -f - as shorthand; handled by fArg assignment above
}
}
const targets = [];
const errors = [];
const warnings = [];
if (pArg) {
const parts = pArg.split(',').map(s => s.trim()).filter(Boolean);
for (const raw of parts) {
const parsed = parsePkgSpecFromArg(raw);
if (!parsed) {
errors.push(`Invalid package spec '${raw}' in -p`);
continue;
}
if (!isValidPackageName(parsed.name)) {
errors.push(`Invalid package name '${parsed.name}' in -p`);
continue;
}
if (!isValidVersion(parsed.version)) {
errors.push(`Invalid version '${parsed.version}' for package '${parsed.name}' in -p`);
continue;
}
targets.push(parsed);
}
}
// If stdin should be read: conditions:
// - user passed --stdin, or
// - user passed -f - (fArg === '-') , or
// - no -p/-f provided and stdin is piped (non-TTY)
const shouldReadStdin = stdinFlag || fArg === '-' || (!pArg && !fArg && !process.stdin.isTTY);
if (shouldReadStdin) {
// read stdin line-by-line
try {
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
let idx = 0;
for await (const ln of rl) {
idx++;
const parsed = parsePkgSpecFromFileLine(ln);
if (!parsed) continue; // blank or comment
if (!isValidPackageName(parsed.name)) {
warnings.push(`Warning: invalid package name on stdin:${idx} -> '${ln.trim()}' (ignored)`);
continue;
}
if (!isValidVersion(parsed.version)) {
warnings.push(`Warning: invalid version on stdin:${idx} -> '${ln.trim()}' (ignored)`);
continue;
}
targets.push(parsed);
}
} catch (err) {
console.error('Failed to read stdin:', err && err.message ? err.message : err);
process.exit(7);
}
}
// If fArg is a real filename (not '-') process it now
if (fArg && fArg !== '-') {
// read file and process lines; invalid lines should only warn and be ignored
let lines;
try {
const raw = fs.readFileSync(fArg, 'utf8');
lines = raw.split(/\r?\n/);
} catch (err) {
console.error(`Could not read file '${fArg}': ${err.message}`);
process.exit(2);
}
lines.forEach((ln, idx) => {
const parsed = parsePkgSpecFromFileLine(ln);
if (!parsed) return; // blank or comment
if (!isValidPackageName(parsed.name)) {
warnings.push(`Warning: invalid package name on ${fArg}:${idx + 1} -> '${ln.trim()}' (ignored)`);
return;
}
if (!isValidVersion(parsed.version)) {
warnings.push(`Warning: invalid version on ${fArg}:${idx + 1} -> '${ln.trim()}' (ignored)`);
return;
}
targets.push(parsed);
});
}
if (!targets.length) {
console.error('No valid package targets provided. Exiting.');
errors.forEach(e => console.error(e));
warnings.forEach(w => console.warn(w));
process.exit(3);
}
// Show warnings and errors
if (errors.length) {
errors.forEach(e => console.error(e));
process.exit(4);
}
warnings.forEach(w => console.warn(w));
// discover files (allow overriding start directory)
const start = rArg ? path.resolve(rArg) : process.cwd();
if (rArg) {
try {
const st = fs.statSync(start);
if (!st.isDirectory()) {
console.error(`Provided root '${rArg}' is not a directory`);
process.exit(6);
}
} catch (err) {
console.error(`Provided root '${rArg}' does not exist or is not accessible: ${err.message}`);
process.exit(6);
}
}
const files = findTargetFiles(start);
if (!files.length) {
console.error('No target files (pnpm-lock.yaml, yarn.lock, package.json) found under ' + start);
process.exit(5);
}
console.log('Discovered target files:');
files.forEach(f => console.log(' - ' + f));
// build regex objects
const built = targets.map(t => buildRegexesForPackage(t));
const results = {};
for (const b of built) {
results[b.name + (b.version ? '@' + b.version : '')] = { pkg: b, hits: [] };
}
for (const fpath of files) {
for (const b of built) {
const hits = searchFileForPackage(fpath, b);
if (hits && hits.length) {
results[b.name + (b.version ? '@' + b.version : '')].hits.push({ file: fpath, hits });
}
}
}
// Print summary
console.log('Search summary:');
Object.keys(results).forEach(key => {
const entry = results[key];
if (!entry.hits.length) {
console.log(`- ${key}: NOT FOUND`);
return;
}
console.log(`- ${key}: FOUND in ${entry.hits.length} file(s):`);
for (const fh of entry.hits) {
console.log(` - ${fh.file}`);
for (const h of fh.hits) {
console.log(` line ${h.line}: version=${h.version || '(unknown)'} snippet: ${h.snippet.replace(/\n/g, ' -> ').slice(0, 250)}`);
}
}
});
}
// Run main
if (require.main === module) {
main().catch(err => {
console.error('Unhandled error:', err && err.stack ? err.stack : err);
process.exit(99);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment