np-audit 1.2.1 → 1.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/detector.js +81 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "np-audit",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Static obfuscation detector for npm lifecycle scripts — supply chain attack prevention",
5
5
  "bin": {
6
6
  "npa": "bin/npa.js",
package/src/detector.js CHANGED
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // ─── Constants ───────────────────────────────────────────────────────────────
4
+
5
+ const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
6
+
3
7
  // ─── Individual detection checks ─────────────────────────────────────────────
4
8
 
5
9
  /**
@@ -22,31 +26,57 @@ function checkEval(code) {
22
26
 
23
27
  /**
24
28
  * Detect obfuscator.io signature: _0x variable naming.
29
+ * Score scales with density of obfuscation.
25
30
  * @param {string} code
26
31
  * @returns {Finding|null}
27
32
  */
28
33
  function checkObfuscatorIo(code) {
29
34
  const matches = code.match(/_0x[0-9a-fA-F]+/g) || [];
30
35
  if (matches.length < 3) return null;
31
- return { name: 'obfuscator.io', score: 9, detail: `${matches.length} _0x identifiers found` };
36
+ // Scale score: 3-10 = 9, 11-50 = 15, 51-200 = 30, 201-1000 = 50, 1000+ = 80
37
+ let score = 9;
38
+ if (matches.length > 1000) score = 80;
39
+ else if (matches.length > 200) score = 50;
40
+ else if (matches.length > 50) score = 30;
41
+ else if (matches.length > 10) score = 15;
42
+ return { name: 'obfuscator.io', score, detail: `${matches.length} _0x identifiers found` };
32
43
  }
33
44
 
34
45
  /**
35
46
  * Detect high-entropy strings (likely encoded/encrypted payloads).
47
+ * Uses indexOf-based extraction to avoid regex stack overflow on large files.
36
48
  * @param {string} code
37
49
  * @returns {Finding|null}
38
50
  */
39
51
  function checkHighEntropy(code) {
40
- // Extract string literals (single, double, template)
41
- const stringRe = /(?:"([^"\\]|\\.){50,}"|'([^'\\]|\\.){50,}'|`([^`\\]|\\.){50,}`)/g;
42
- let match;
43
52
  let maxEntropy = 0;
44
53
  let worst = '';
45
- while ((match = stringRe.exec(code)) !== null) {
46
- const s = match[0].slice(1, -1);
47
- const e = shannonEntropy(s);
48
- if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
54
+ const minLen = 50;
55
+
56
+ // Simple string extraction without complex regex
57
+ for (const quote of ['"', "'", '`']) {
58
+ let pos = 0;
59
+ while (pos < code.length) {
60
+ const start = code.indexOf(quote, pos);
61
+ if (start === -1) break;
62
+
63
+ // Find end quote (skip escaped quotes)
64
+ let end = start + 1;
65
+ while (end < code.length) {
66
+ if (code[end] === '\\') { end += 2; continue; }
67
+ if (code[end] === quote) break;
68
+ end++;
69
+ }
70
+
71
+ if (end < code.length && end - start - 1 >= minLen) {
72
+ const s = code.slice(start + 1, end);
73
+ const e = shannonEntropy(s);
74
+ if (e > maxEntropy) { maxEntropy = e; worst = s.slice(0, 40); }
75
+ }
76
+ pos = end + 1;
77
+ }
49
78
  }
79
+
50
80
  if (maxEntropy < 4.5) return null;
51
81
  return {
52
82
  name: 'high-entropy-string',
@@ -57,13 +87,19 @@ function checkHighEntropy(code) {
57
87
 
58
88
  /**
59
89
  * Detect dense hex escape sequences (\x41).
90
+ * Score scales with volume.
60
91
  * @param {string} code
61
92
  * @returns {Finding|null}
62
93
  */
63
94
  function checkHexEscapes(code) {
64
95
  const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
65
96
  if (hexMatches < 10) return null;
66
- return { name: 'hex-escape-density', score: 5, detail: `${hexMatches} \\xNN hex escapes found` };
97
+ // Scale: 10-50 = 5, 51-200 = 15, 201-1000 = 30, 1000+ = 50
98
+ let score = 5;
99
+ if (hexMatches > 1000) score = 50;
100
+ else if (hexMatches > 200) score = 30;
101
+ else if (hexMatches > 50) score = 15;
102
+ return { name: 'hex-escape-density', score, detail: `${hexMatches} \\xNN hex escapes found` };
67
103
  }
68
104
 
69
105
  /**
@@ -119,6 +155,7 @@ function checkChildProcess(code) {
119
155
 
120
156
  /**
121
157
  * Detect large hex literal arrays (common in minified obfuscated code).
158
+ * Score scales with volume.
122
159
  * @param {string} code
123
160
  * @returns {Finding|null}
124
161
  */
@@ -126,7 +163,12 @@ function checkHexArray(code) {
126
163
  // Count 0x1234-style literals
127
164
  const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
128
165
  if (hexLiterals < 20) return null;
129
- return { name: 'hex-array', score: 7, detail: `${hexLiterals} hex literal values found` };
166
+ // Scale: 20-100 = 7, 101-500 = 20, 501-2000 = 40, 2000+ = 60
167
+ let score = 7;
168
+ if (hexLiterals > 2000) score = 60;
169
+ else if (hexLiterals > 500) score = 40;
170
+ else if (hexLiterals > 100) score = 20;
171
+ return { name: 'hex-array', score, detail: `${hexLiterals} hex literal values found` };
130
172
  }
131
173
 
132
174
  /**
@@ -190,22 +232,45 @@ const CHECKS = [
190
232
 
191
233
  /**
192
234
  * Run all checks against a code string.
235
+ * For large files, analyzes multiple chunks and aggregates results.
193
236
  * @param {string} code
194
237
  * @param {object} config { blockScore, warnScore }
195
238
  * @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
196
239
  */
197
- function detectObfuscation(code, config = { blockScore: 7, warnScore: 4 }) {
240
+ function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
198
241
  if (!code || typeof code !== 'string') {
199
242
  return { score: 0, findings: [], verdict: 'OK' };
200
243
  }
201
244
 
202
- const findings = [];
203
- for (const check of CHECKS) {
204
- const result = check(code);
205
- if (result) findings.push(result);
245
+ // For large files, analyze chunks and take worst results
246
+ const chunks = [];
247
+ if (code.length > MAX_CODE_SIZE) {
248
+ // Analyze start, middle, and end chunks
249
+ chunks.push(code.slice(0, MAX_CODE_SIZE));
250
+ const mid = Math.floor(code.length / 2) - Math.floor(MAX_CODE_SIZE / 2);
251
+ chunks.push(code.slice(mid, mid + MAX_CODE_SIZE));
252
+ chunks.push(code.slice(-MAX_CODE_SIZE));
253
+ } else {
254
+ chunks.push(code);
206
255
  }
207
256
 
208
- // Score = highest individual finding score (weighted max avoid double-penalizing)
257
+ const allFindings = new Map(); // Dedupe by name, keep highest score
258
+
259
+ for (const chunk of chunks) {
260
+ for (const check of CHECKS) {
261
+ const result = check(chunk);
262
+ if (result) {
263
+ const existing = allFindings.get(result.name);
264
+ if (!existing || result.score > existing.score) {
265
+ allFindings.set(result.name, result);
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ const findings = Array.from(allFindings.values());
272
+
273
+ // Score = highest individual finding score
209
274
  const score = findings.length > 0
210
275
  ? Math.max(...findings.map(f => f.score))
211
276
  : 0;