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.
- package/README.md +10 -0
- package/cli.js +56 -23
- package/package.json +1 -1
- 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
|
-
${
|
|
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
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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
|
-
|
|
22
|
-
|
|
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 } =
|
|
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
|
|
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
|
|