vuln-scan 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +10 -0
  2. package/cli.js +56 -23
  3. package/package.json +1 -1
  4. package/src/core.js +77 -5
package/README.md CHANGED
@@ -27,6 +27,12 @@ A Node.js CLI that scans a project’s **lockfile** (npm / pnpm / yarn) to find
27
27
  npx vuln-scan
28
28
  ```
29
29
 
30
+ ### Run with pnpm
31
+
32
+ ```bash
33
+ pnpm dlx vuln-scan
34
+ ```
35
+
30
36
  ### Install globally
31
37
 
32
38
  ```bash
@@ -62,4 +68,8 @@ node ./cli.js
62
68
  node ./cli.js --json
63
69
  ```
64
70
 
71
+ ## Security
72
+
73
+ See [SECURITY.md](SECURITY.md) for vulnerability reporting and PGP details.
74
+
65
75
 
package/cli.js CHANGED
@@ -1,26 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { setDefaultResultOrder } from 'node:dns';
4
+ import { createRequire } from 'node:module';
3
5
  import chalk from 'chalk';
4
6
  import ora from 'ora';
5
7
  import Table from 'cli-table3';
6
8
 
7
9
  import { scanProject } from './src/core.js';
8
10
 
11
+ const require = createRequire(import.meta.url);
12
+ const { version: CLI_VERSION } = require('./package.json');
13
+
14
+ function brandingLine() {
15
+ return `${chalk.bold('vuln-scan')}${chalk.gray(' - ')}${chalk.cyan('Debasis')}`;
16
+ }
17
+
9
18
  function printHelp() {
19
+ const valueLine = chalk.gray(`v${CLI_VERSION}`);
20
+ const features = chalk.cyan('Fast | Secure | Protect');
21
+ const tagline = chalk.gray('Scan Dependencies - Stay Safe - Fix Quickly');
22
+
10
23
  const text = `
11
- ${chalk.bold('vuln-scan')}
24
+ ${features}
25
+ ${tagline}
26
+
27
+ ${chalk.bold('vuln-scan')} ${valueLine}
12
28
 
13
29
  Scans a project's lockfile (npm/pnpm/yarn) for known vulnerabilities using the OSV.dev API.
14
30
 
31
+ Made by Debasis (https://github.com/DebaA17)
32
+
15
33
  Usage:
16
34
  npx vuln-scan
35
+ pnpm dlx vuln-scan
17
36
  vuln-scan
18
37
 
19
38
  Options:
20
39
  --json Output machine-readable JSON
21
40
  --help Show this help
22
41
  `;
23
- // eslint-disable-next-line no-console
24
42
  console.log(text.trim());
25
43
  }
26
44
 
@@ -39,7 +57,27 @@ function severityColor(sev) {
39
57
  }
40
58
  }
41
59
 
60
+ // Compare two semantic versions (returns true if v1 < v2)
61
+ function isVulnerable(installedVersion, fixedVersion) {
62
+ const installedParts = installedVersion.split('.').map(Number);
63
+ const fixedParts = fixedVersion.split('.').map(Number);
64
+
65
+ for (let i = 0; i < Math.max(installedParts.length, fixedParts.length); i++) {
66
+ const installed = installedParts[i] || 0;
67
+ const fixed = fixedParts[i] || 0;
68
+ if (installed < fixed) return true;
69
+ if (installed > fixed) return false;
70
+ }
71
+ return false; // versions are equal → not vulnerable
72
+ }
73
+
42
74
  async function main() {
75
+ try {
76
+ setDefaultResultOrder('ipv4first');
77
+ } catch {
78
+ // Older Node versions may not support this
79
+ }
80
+
43
81
  const args = new Set(process.argv.slice(2));
44
82
  if (args.has('--help') || args.has('-h')) {
45
83
  printHelp();
@@ -48,31 +86,34 @@ async function main() {
48
86
 
49
87
  const jsonOutput = args.has('--json');
50
88
 
51
- const spinner = ora({ text: 'Scanning dependencies', spinner: 'dots' }).start();
89
+ const spinner = ora({ text: 'Scanning dependencies...', spinner: 'dots' }).start();
52
90
 
53
91
  try {
54
92
  const result = await scanProject({
55
93
  cwd: process.cwd(),
56
94
  onProgress: ({ processed, total }) => {
57
- spinner.text = `Scanning dependencies ${processed}/${total}`;
95
+ spinner.text = `Scanning dependencies... ${processed}/${total}`;
58
96
  }
59
97
  });
60
98
 
61
99
  spinner.stop();
62
100
 
101
+ // Filter vulnerabilities: only show if installed version < fixed version
102
+ const activeVulns = result.vulnerabilities.filter(v => {
103
+ if (!v.fixed) return true; // no fixed info, assume still vulnerable
104
+ return isVulnerable(v.version, v.fixed);
105
+ });
106
+
63
107
  if (jsonOutput) {
64
- // Keep stdout clean for piping/automation.
65
- // eslint-disable-next-line no-console
66
- console.log(JSON.stringify(result, null, 2));
67
- // eslint-disable-next-line no-console
108
+ console.log(JSON.stringify({ ...result, vulnerabilities: activeVulns }, null, 2));
109
+ console.error(brandingLine());
68
110
  console.error(chalk.green('Scan complete!'));
69
111
  return;
70
112
  }
71
113
 
72
- if (result.vulnerabilities.length === 0) {
73
- // eslint-disable-next-line no-console
114
+ if (activeVulns.length === 0) {
74
115
  console.log(chalk.green(`No known vulnerabilities found in ${result.dependencyCount} dependencies.`));
75
- // eslint-disable-next-line no-console
116
+ console.log(brandingLine());
76
117
  console.log(chalk.green('Scan complete!'));
77
118
  return;
78
119
  }
@@ -80,33 +121,25 @@ async function main() {
80
121
  const table = new Table({
81
122
  head: ['Package', 'CVE ID', 'Severity', 'Summary'],
82
123
  wordWrap: true,
83
- colWidths: [
84
- 28,
85
- 18,
86
- 10,
87
- 70
88
- ]
124
+ colWidths: [28, 18, 10, 70]
89
125
  });
90
126
 
91
- for (const v of result.vulnerabilities) {
127
+ for (const v of activeVulns) {
92
128
  const pkg = `${v.package}@${v.version}`;
93
129
  const cve = v.cve || v.id;
94
130
  const summary = v.summary + (v.fixed ? ` (Fix: ${v.fixed})` : '');
95
-
96
131
  table.push([pkg, cve, severityColor(v.severity), summary]);
97
132
  }
98
133
 
99
- // eslint-disable-next-line no-console
100
134
  console.log(table.toString());
101
- // eslint-disable-next-line no-console
135
+ console.log(brandingLine());
102
136
  console.log(chalk.green('Scan complete!'));
103
137
  } catch (err) {
104
138
  spinner.stop();
105
139
  const message = err instanceof Error ? err.message : String(err);
106
- // eslint-disable-next-line no-console
107
140
  console.error(chalk.red(`Error: ${message}`));
108
141
  process.exitCode = 1;
109
142
  }
110
143
  }
111
144
 
112
- await main();
145
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vuln-scan",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Node.js CLI to scan dependency lockfiles for vulnerabilities using OSV.dev",
5
5
  "type": "module",
6
6
  "bin": {
package/src/core.js CHANGED
@@ -18,11 +18,24 @@ const OSV_QUERY_URL = 'https://api.osv.dev/v1/query';
18
18
  export async function scanProject({ cwd, onProgress }) {
19
19
  const projectPackageJsonPath = path.join(cwd, 'package.json');
20
20
  const hasPackageJson = await exists(projectPackageJsonPath);
21
- if (!hasPackageJson) {
22
- throw new Error('Missing package.json in the current directory.');
21
+
22
+ let detected;
23
+ try {
24
+ detected = await detectPackageManager(cwd);
25
+ } catch {
26
+ if (hasPackageJson) {
27
+ throw new Error(
28
+ 'No supported lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock). ' +
29
+ 'Run your package manager install command to generate a lockfile, then re-run vuln-scan.'
30
+ );
31
+ }
32
+ throw new Error(
33
+ 'Nothing to scan: no package.json or supported lockfile found (package-lock.json, pnpm-lock.yaml, yarn.lock). '
34
+ + 'Run vuln-scan from a project directory.'
35
+ );
23
36
  }
24
37
 
25
- const { packageManager, lockfilePath } = await detectPackageManager(cwd);
38
+ const { packageManager, lockfilePath } = detected;
26
39
 
27
40
  const dependencies = await readDependenciesFromLockfile({
28
41
  packageManager,
@@ -320,10 +333,12 @@ async function queryOsv({ name, version }) {
320
333
  }
321
334
  };
322
335
 
323
- const res = await fetch(OSV_QUERY_URL, {
336
+ const res = await fetchWithRetry(OSV_QUERY_URL, {
324
337
  method: 'POST',
325
338
  headers: {
326
- 'content-type': 'application/json'
339
+ 'content-type': 'application/json',
340
+ // A friendly UA can help with debugging/telemetry on the server side.
341
+ 'user-agent': 'vuln-scan (node)'
327
342
  },
328
343
  body: JSON.stringify(body)
329
344
  });
@@ -336,6 +351,63 @@ async function queryOsv({ name, version }) {
336
351
  return res.json();
337
352
  }
338
353
 
354
+ async function fetchWithRetry(url, init) {
355
+ const retries = 3;
356
+ const timeoutMs = 15_000;
357
+
358
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
359
+ const controller = new AbortController();
360
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
361
+
362
+ try {
363
+ // eslint-disable-next-line no-undef
364
+ return await fetch(url, {
365
+ ...init,
366
+ signal: controller.signal
367
+ });
368
+ } catch (err) {
369
+ const isLast = attempt === retries;
370
+ const message = err instanceof Error ? err.message : String(err);
371
+ const code = extractNodeErrorCode(err);
372
+
373
+ if (isLast) {
374
+ // Provide a more actionable message than the generic "fetch failed".
375
+ if (code === 'ETIMEDOUT' || code === 'ENETUNREACH' || code === 'EAI_AGAIN') {
376
+ throw new Error(
377
+ `Network error reaching OSV.dev (${code}). ` +
378
+ `If you're on an IPv6-restricted network, try: NODE_OPTIONS=--dns-result-order=ipv4first vuln-scan`
379
+ );
380
+ }
381
+ throw new Error(`Failed to reach OSV.dev: ${message}`);
382
+ }
383
+
384
+ // Retry transient network issues / timeouts.
385
+ if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENETUNREACH' || code === 'EAI_AGAIN') {
386
+ await delay(250 * Math.pow(2, attempt));
387
+ continue;
388
+ }
389
+
390
+ // AbortError or other non-network errors should fail fast.
391
+ throw err;
392
+ } finally {
393
+ clearTimeout(timeout);
394
+ }
395
+ }
396
+
397
+ // Unreachable.
398
+ throw new Error('Failed to reach OSV.dev.');
399
+ }
400
+
401
+ function extractNodeErrorCode(err) {
402
+ // Node/undici errors often have nested causes.
403
+ const code = err?.cause?.code || err?.code;
404
+ return typeof code === 'string' ? code : null;
405
+ }
406
+
407
+ function delay(ms) {
408
+ return new Promise((resolve) => setTimeout(resolve, ms));
409
+ }
410
+
339
411
  function normalizeOsvResponse({ dependency, response }) {
340
412
  const vulns = Array.isArray(response?.vulns) ? response.vulns : [];
341
413