np-audit 1.4.0 → 1.5.1

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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/np-audit.svg)](https://www.npmjs.com/package/np-audit)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/np-audit.svg)](https://www.npmjs.com/package/np-audit)
5
+ [![npm package size](https://img.shields.io/npm/unpacked-size/np-audit)](https://www.npmjs.com/package/np-audit)
5
6
  [![GitHub license](https://img.shields.io/github/license/KoblerS/np-audit.svg)](https://github.com/KoblerS/np-audit/blob/main/LICENSE)
6
7
  [![CI](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml)
7
8
 
@@ -39,22 +40,44 @@ Real-world examples include:
39
40
  - **[ua-parser-js (2021)](https://github.com/advisories/GHSA-pjwm-rvh2-c87w)** — cryptocurrency miner + info-stealer injected via hijacked maintainer account
40
41
  - **[node-ipc (2022)](https://snyk.io/blog/peacenotwar-malicious-npm-node-ipc-package-vulnerability/)** — wiper malware targeting systems by geo-IP
41
42
  - **[colors / faker (2022)](https://snyk.io/blog/open-source-npm-packages-colors-faker/)** — deliberate sabotage by the maintainer via postinstall
42
- - **XZ Utils (2024)** — multi-year social engineering + backdoor via build scripts (C ecosystem, but same pattern)
43
+ - **[SAP CAP / cds-dbs (2025)](https://community.sap.com/t5/technology-blog-posts-by-sap/cap-developers-call-to-action-to-mitigate-and-apply-solution-provided-in/ba-p/14387683)** — compromised npm package targeting SAP developers
43
44
 
44
45
  **`npa` never executes the scripts.** It downloads and statically analyzes them, detecting:
45
46
 
46
- | Signal | Example |
47
- |---|---|
48
- | `eval()` / `new Function()` | `eval(atob("aGVsbG8="))` |
49
- | Obfuscator.io patterns | `var _0x3f2a = [...]` |
50
- | High-entropy strings | Encrypted/compressed payloads |
51
- | Hex escape density | `\x68\x65\x6c\x6c\x6f` |
47
+ | Signal | Example |
48
+ | ------------------------------ | ------------------------------------------ |
49
+ | `eval()` / `new Function()` | `eval(atob("aGVsbG8="))` |
50
+ | Indirect eval | `(0, eval)(x)`, `globalThis['ev'+'al'](x)` |
51
+ | Function constructor prototype | `({}).constructor.constructor("…")()` |
52
+ | `setTimeout` with string arg | `setTimeout('alert(1)', 100)` |
53
+ | Obfuscator.io patterns | `var _0x3f2a = [...]` |
54
+ | High-entropy strings | Encrypted/compressed payloads |
55
+ | Split-literal high entropy | `'aB' + 'cD' + 'eF' + …` (concat chain) |
56
+ | Hex / Unicode escape density | `\x68\x65\x6c\x6c\x6f`, `\u0068\u0065…` |
52
57
  | `String.fromCharCode()` chains | `String.fromCharCode(104,101,108,108,111)` |
53
- | Base64 decode + exec | `Buffer.from(x,'base64')` + `eval` |
54
- | Shell spawning | `require('child_process').exec(...)` |
55
- | Large hex literal arrays | `[0x1a, 0x2b, 0x3c, ...]` × 20+ |
56
- | `process.env` access | Token/credential harvesting |
57
- | Outbound network calls | Data exfiltration |
58
+ | Decimal char-code arrays | `[101,118,97,108,...]` (printable ASCII) |
59
+ | Base64 / hex decode + exec | `Buffer.from(x,'base64'\|'hex')` + `eval` |
60
+ | Shell spawning | `require('child_process').exec(...)` |
61
+ | `worker_threads` | `new Worker(...)`, eval-class surface |
62
+ | Concealed `require` | `require('child' + '_process')` |
63
+ | Dynamic `require` / `import` | `require(variable)`, `import(expr)` |
64
+ | Large hex literal arrays | `[0x1a, 0x2b, 0x3c, ...]` × 20+ |
65
+ | `process.env` access | Token/credential harvesting |
66
+ | Outbound network calls | `https`/`http`/`net`/`dns`/`tls`/`dgram`/`http2`, `node:` prefix, dynamic `import()` |
67
+ | Missing referenced script | Command references file not in tarball |
68
+ | Oversized require graph | Postinstall reaches >50 files or >5 MB |
69
+
70
+ ### Coverage beyond the entry script
71
+
72
+ `npa` doesn't just inspect the single file named in the lifecycle command. It:
73
+
74
+ - Splits chained commands (`&&`, `||`, `;`, `|`) and analyses each segment, so `node setup.js && node payload.js` no longer hides the second invocation.
75
+ - Handles `node -e "…"`, `sh -c "…"`, `python -c "…"` by analysing the inline code rather than just the wrapper command.
76
+ - Reads shell scripts (`sh ./install.sh`), Python scripts, and shebang-invoked files — not only `node` targets.
77
+ - Follows internal `require('./…')` and `import './…'` chains across the package (with cycle detection and per-package caps), so payloads hidden in helper files like `lib/helper.js` are caught.
78
+ - Records dynamic `require(variable)` / `import(expr)` calls as findings, since they can't be resolved statically.
79
+ - Scans the project's **own** `package.json` lifecycle scripts by default, catching PRs and supply-chain attacks that target the repository itself. Opt out with `scanSelf: false` in `.npmauditor.json`.
80
+ - Inspects **all** lifecycle scripts npm may run, not only `preinstall`/`install`/`postinstall`: also `prepare`, `preprepare`, `postprepare`, and `prepublish`.
58
81
 
59
82
  ---
60
83
 
@@ -84,29 +107,29 @@ npa --version
84
107
 
85
108
  ### Commands
86
109
 
87
- | Command | Alias | Description |
88
- |---|---|---|
89
- | `npa install [package]` | `npa i` | Audit then run `npm install` |
90
- | `npa ci` | — | Audit then run `npm ci` |
91
- | `npa scan` | `npa s` | Scan only, no install |
92
- | `npa config get` | `npa c get` | Show current configuration |
93
- | `npa config set <key> <value>` | `npa c set` | Update a config value |
94
- | `npa alias` | — | Print shell hook for auto-scanning |
95
- | `npa alias --install` | — | Install hook to shell profile |
96
- | `npa alias --uninstall` | — | Remove hook from shell profile |
110
+ | Command | Alias | Description |
111
+ | ------------------------------ | ----------- | ---------------------------------- |
112
+ | `npa install [package]` | `npa i` | Audit then run `npm install` |
113
+ | `npa ci` | — | Audit then run `npm ci` |
114
+ | `npa scan` | `npa s` | Scan only, no install |
115
+ | `npa config get` | `npa c get` | Show current configuration |
116
+ | `npa config set <key> <value>` | `npa c set` | Update a config value |
117
+ | `npa alias` | — | Print shell hook for auto-scanning |
118
+ | `npa alias --install` | — | Install hook to shell profile |
119
+ | `npa alias --uninstall` | — | Remove hook from shell profile |
97
120
 
98
121
  Use `npa <command> -h` for detailed help on any command.
99
122
 
100
123
  ### Flags
101
124
 
102
- | Flag | Alias | Works with | Description |
103
- |---|---|---|---|
104
- | `--aware` | `-a` | `install`, `ci` | Interactive mode — choose which scripts to allow |
105
- | `--json` | — | `install`, `ci`, `scan` | Machine-readable JSON output |
106
- | `--no-dev` | — | `install`, `ci`, `scan` | Skip devDependencies |
107
- | `--verbose` | — | all | Show fetch progress and extra detail |
108
- | `--version` | — | — | Print version and exit |
109
- | `--help` | `-h` | — | Print help and exit |
125
+ | Flag | Alias | Works with | Description |
126
+ | ----------- | ----- | ----------------------- | ------------------------------------------------ |
127
+ | `--review` | `-r` | `install`, `ci` | Interactive mode — choose which scripts to allow |
128
+ | `--json` | — | `install`, `ci`, `scan` | Machine-readable JSON output |
129
+ | `--no-dev` | — | `install`, `ci`, `scan` | Skip devDependencies |
130
+ | `--verbose` | — | all | Show fetch progress and extra detail |
131
+ | `--version` | — | — | Print version and exit |
132
+ | `--help` | `-h` | — | Print help and exit |
110
133
 
111
134
  ### Drop-in replacement for `npm install`
112
135
 
@@ -133,23 +156,23 @@ npa s --no-dev # skip devDependencies
133
156
  npa s --verbose # show fetch progress
134
157
  ```
135
158
 
136
- ### Interactive `--aware` mode
159
+ ### Interactive `--review` mode
137
160
 
138
161
  Review each install script yourself and decide which to allow:
139
162
 
140
163
  ```bash
141
- npa i --aware # or: npa i -a
142
- npa ci --aware
164
+ npa i --review # or: npa i -r
165
+ npa ci --review
143
166
  ```
144
167
 
145
168
  ```
146
- npa --aware mode
169
+ npa --review mode
147
170
  Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit
148
171
 
149
172
  Found 3 package(s) with install scripts:
150
173
 
151
174
  [✓ allow] esbuild@0.24.2 postinstall: post-install.js OK
152
- ▶ [✗ deny ] evil-sdk@1.0.0 postinstall: install.js BLOCK (score: 9)
175
+ ▶ [✗ deny ] evil-sdk@1.0.0 postinstall: install.js DANGER (score: 9)
153
176
  [✓ allow] @scope/pkg@2.1.0 postinstall: install.js WARN (score: 5)
154
177
 
155
178
  2 allowed 1 denied
@@ -200,8 +223,8 @@ If issues are found, the install is blocked:
200
223
  ```bash
201
224
  $ npm install evil-pkg
202
225
  [npa] Scanning dependencies before npm install...
203
- ✗ evil-pkg@1.0.0 BLOCK (score: 9)
204
- [npa] Scan found issues. Run 'npa install --aware' for interactive mode.
226
+ ✗ evil-pkg@1.0.0 DANGER (score: 9)
227
+ [npa] Scan found issues. Run 'npa install --review' for interactive mode.
205
228
  ```
206
229
 
207
230
  To remove the hook:
@@ -212,24 +235,26 @@ npa alias --uninstall
212
235
 
213
236
  #### All config keys
214
237
 
215
- | Key | Default | Description |
216
- |---|---|---|
217
- | `blockScore` | `7` | Score threshold for hard block (exit 1) |
218
- | `warnScore` | `4` | Score threshold for warning (exit 0) |
219
- | `registry` | `https://registry.npmjs.org` | npm registry URL |
220
- | `timeout` | `30000` | HTTP request timeout (ms) |
221
- | `parallelFetches` | `5` | Concurrent tarball downloads |
222
- | `skipScopes` | `[]` | `@scope` prefixes to skip entirely |
223
- | `skipPackages` | `[]` | Specific package names to skip |
238
+ | Key | Default | Description |
239
+ | ----------------- | ---------------------------- | --------------------------------------- |
240
+ | `blockScore` | `7` | Score threshold for hard block (exit 1) |
241
+ | `warnScore` | `4` | Score threshold for warning (exit 0) |
242
+ | `registry` | `https://registry.npmjs.org` | npm registry URL |
243
+ | `timeout` | `30000` | HTTP request timeout (ms) |
244
+ | `parallelFetches` | `5` | Concurrent tarball downloads |
245
+ | `skipScopes` | `[]` | `@scope` prefixes to skip entirely |
246
+ | `skipPackages` | `[]` | Specific package names to skip |
247
+ | `silent` | `false` | Suppress output when no issues found |
248
+ | `scanSelf` | `true` | Also scan the current project's own `package.json` lifecycle scripts |
224
249
 
225
250
  ---
226
251
 
227
252
  ## Exit codes
228
253
 
229
- | Code | Meaning |
230
- |---|---|
231
- | `0` | All packages clean or only warnings |
232
- | `1` | One or more packages blocked |
254
+ | Code | Meaning |
255
+ | ---- | ----------------------------------- |
256
+ | `0` | All packages clean or only warnings |
257
+ | `1` | One or more packages blocked |
233
258
 
234
259
  ---
235
260
 
@@ -238,10 +263,13 @@ npa alias --uninstall
238
263
  1. **Parse** `package-lock.json` (supports v1, v2, v3 formats)
239
264
  2. **Filter** packages: skip dev deps (`--no-dev`), skipped scopes/packages, packages without install scripts
240
265
  3. **Fetch or read** — for packages in `node_modules`: read from disk. For packages not yet installed: download the tarball from the npm registry and parse it in memory (pure Node.js tar.gz reader, no `tar` package)
241
- 4. **Analyze** each `preinstall`/`install`/`postinstall` script file statically never execute
242
- 5. **Score** findings (0–10 per signal), classify as BLOCK / WARN / OK based on config thresholds
243
- 6. **Report** results to terminal or `--json`
244
- 7. **Proceed** run npm normally, or in `--aware` mode let you selectively allow scripts
266
+ 4. **Parse the lifecycle command** — split on `&&` / `||` / `;` / `|`, classify each segment by interpreter (`node`, `sh`, `python`, `bun`, …), and treat `node -e` / `sh -c` arguments as inline code
267
+ 5. **Walk the require/import graph** from each entry script following internal `./` / `../` paths, with cycle detection and per-package caps (50 files / 5 MB)
268
+ 6. **Analyze** every reached file statically across all lifecycle scripts (`preinstall`, `install`, `postinstall`, `prepare`, `preprepare`, `postprepare`, `prepublish`) — never execute
269
+ 7. **Also scan the current project's own** `package.json` lifecycle scripts (unless `scanSelf: false`)
270
+ 8. **Score** findings (0–10 per signal), classify as DANGER / WARN / OK based on config thresholds
271
+ 9. **Report** results to terminal or `--json` — each finding is tagged with the file it came from
272
+ 10. **Proceed** — run npm normally, or in `--review` mode let you selectively allow scripts
245
273
 
246
274
  ---
247
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "np-audit",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
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/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { loadConfig, DEFAULT_CONFIG } = require('./utils/config');
4
+ const { checkForUpdate } = require('./utils/updateChecker');
4
5
  const commands = require('./commands');
5
6
  const output = require('./utils/output');
6
7
 
@@ -37,6 +38,7 @@ ${lines.join('\n')}
37
38
  parallelFetches Concurrent downloads (default: ${DEFAULT_CONFIG.parallelFetches})
38
39
  skipScopes Array of @scopes to skip
39
40
  skipPackages Array of package names to skip
41
+ maxTarballSize Max unpacked tarball size (default: ${DEFAULT_CONFIG.maxTarballSize})
40
42
  `;
41
43
  }
42
44
 
@@ -108,7 +110,20 @@ async function main() {
108
110
  process.exit(1);
109
111
  }
110
112
 
113
+ // Fire update check only for scan/install/ci commands (non-blocking)
114
+ const UPDATE_COMMANDS = ['scan', 's', 'install', 'i', 'ci'];
115
+ const updatePromise = (UPDATE_COMMANDS.includes(command) && !flags.json && !config.silent)
116
+ ? checkForUpdate(config, VERSION)
117
+ : Promise.resolve(null);
118
+
111
119
  await cmd.run({ args, rawArgs, flags, config, cwd });
120
+
121
+ // Print update notice after command output
122
+ const latestVersion = await updatePromise;
123
+ if (latestVersion) {
124
+ output.log('');
125
+ output.log(output.dim(` Update available: ${VERSION} → ${latestVersion} — run "npm i -g np-audit" to update`));
126
+ }
112
127
  }
113
128
 
114
129
  main().catch(err => {
@@ -3,11 +3,12 @@
3
3
  // ─── Constants ───────────────────────────────────────────────────────────────
4
4
 
5
5
  const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
6
+ const CHUNK_STRIDE = 250000; // 50% overlap between adjacent chunks
6
7
 
7
8
  // ─── Individual detection checks ─────────────────────────────────────────────
8
9
 
9
10
  /**
10
- * Detect eval / dynamic code execution.
11
+ * Detect eval / dynamic code execution, including common indirect-eval tricks.
11
12
  * @param {string} code
12
13
  * @returns {Finding|null}
13
14
  */
@@ -18,6 +19,18 @@ function checkEval(code) {
18
19
  /vm\.runInThisContext\s*\(/,
19
20
  /vm\.runInNewContext\s*\(/,
20
21
  /vm\.Script\s*\(/,
22
+ // Indirect eval — (0, eval)(...) is the canonical sloppy-mode trick
23
+ /\(\s*0\s*,\s*eval\s*\)\s*\(/,
24
+ // Bracket access on a global object: global['eval'], globalThis["eval"], window['eval'],
25
+ // and the same with the string built from concatenation: globalThis['ev'+'al']
26
+ /(?:global|globalThis|window|self|this)\s*\[\s*['"`](?:eval|Function)['"`]\s*\]\s*\(/,
27
+ /(?:global|globalThis|window|self|this)\s*\[\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`]){1,}\s*\]\s*\(/,
28
+ // Function constructor accessed via prototype: ({}).constructor.constructor("...")()
29
+ /\.constructor\s*\.\s*constructor\s*\(/,
30
+ // setTimeout/setInterval with a string argument is a legacy eval vector
31
+ /\b(?:setTimeout|setInterval)\s*\(\s*['"`]/,
32
+ // require('vm') hint when combined with run* — covered above by vm.*, but catch the import too
33
+ /require\s*\(\s*['"]vm['"]\s*\)/,
21
34
  ];
22
35
  const matched = patterns.filter(p => p.test(code));
23
36
  if (matched.length === 0) return null;
@@ -45,6 +58,7 @@ function checkObfuscatorIo(code) {
45
58
  /**
46
59
  * Detect high-entropy strings (likely encoded/encrypted payloads).
47
60
  * Uses indexOf-based extraction to avoid regex stack overflow on large files.
61
+ * Also detects concatenation chains used to defeat per-literal entropy checks.
48
62
  * @param {string} code
49
63
  * @returns {Finding|null}
50
64
  */
@@ -77,80 +91,150 @@ function checkHighEntropy(code) {
77
91
  }
78
92
  }
79
93
 
80
- if (maxEntropy < 4.5) return null;
81
- return {
82
- name: 'high-entropy-string',
83
- score: 6,
84
- detail: `Entropy ${maxEntropy.toFixed(2)} in string "${worst}…"`,
85
- };
94
+ if (maxEntropy >= 4.5) {
95
+ return {
96
+ name: 'high-entropy-string',
97
+ score: 6,
98
+ detail: `Entropy ${maxEntropy.toFixed(2)} in string "${worst}…"`,
99
+ };
100
+ }
101
+
102
+ // Concatenation chain: many small string literals joined with `+`.
103
+ // Captures payloads split into <50-char chunks to dodge the per-literal entropy check.
104
+ // We measure the entropy of the *aggregated* literals.
105
+ const concatChainRe = /(?:['"`][^'"`\n]{0,40}['"`]\s*\+\s*){5,}['"`][^'"`\n]{0,40}['"`]/g;
106
+ let m;
107
+ let bestChain = '';
108
+ while ((m = concatChainRe.exec(code)) !== null) {
109
+ const literals = m[0].match(/['"`]([^'"`\n]{0,40})['"`]/g) || [];
110
+ const joined = literals.map(l => l.slice(1, -1)).join('');
111
+ if (joined.length >= 40) {
112
+ const e = shannonEntropy(joined);
113
+ if (e > maxEntropy) { maxEntropy = e; bestChain = joined.slice(0, 40); }
114
+ }
115
+ }
116
+
117
+ if (maxEntropy >= 4.5) {
118
+ return {
119
+ name: 'high-entropy-string',
120
+ score: 6,
121
+ detail: `Entropy ${maxEntropy.toFixed(2)} in concatenated literals "${bestChain}…"`,
122
+ };
123
+ }
124
+
125
+ return null;
86
126
  }
87
127
 
88
128
  /**
89
- * Detect dense hex escape sequences (\x41).
90
- * Score scales with volume.
129
+ * Detect dense \xNN and \uXXXX escape sequences.
130
+ * Score scales with volume; \u and \x are summed.
91
131
  * @param {string} code
92
132
  * @returns {Finding|null}
93
133
  */
94
134
  function checkHexEscapes(code) {
95
- const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
96
- if (hexMatches < 10) return null;
135
+ const hexMatches = (code.match(/\\x[0-9a-fA-F]{2}/g) || []).length;
136
+ const unicodeMatches = (code.match(/\\u[0-9a-fA-F]{4}/g) || []).length
137
+ + (code.match(/\\u\{[0-9a-fA-F]+\}/g) || []).length;
138
+ const total = hexMatches + unicodeMatches;
139
+ if (total < 10) return null;
97
140
  // Scale: 10-50 = 5, 51-200 = 15, 201-1000 = 30, 1000+ = 50
98
141
  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` };
142
+ if (total > 1000) score = 50;
143
+ else if (total > 200) score = 30;
144
+ else if (total > 50) score = 15;
145
+ const detail = unicodeMatches > 0
146
+ ? `${hexMatches} \\xNN + ${unicodeMatches} \\uXXXX escapes found`
147
+ : `${hexMatches} \\xNN hex escapes found`;
148
+ return { name: 'hex-escape-density', score, detail };
103
149
  }
104
150
 
105
151
  /**
106
- * Detect String.fromCharCode with many numeric arguments.
152
+ * Detect String.fromCharCode (and its aliases) with many numeric arguments,
153
+ * and large arrays of character codes that are typically reassembled into strings.
107
154
  * @param {string} code
108
155
  * @returns {Finding|null}
109
156
  */
110
157
  function checkFromCharCode(code) {
111
- const re = /String\.fromCharCode\s*\(([^)]+)\)/g;
112
- let match;
158
+ // Direct (or property-access) call: String.fromCharCode(...) or anyObj.fromCharCode(...)
113
159
  let maxArgs = 0;
114
- while ((match = re.exec(code)) !== null) {
160
+ const direct = /(?:String|[\w$]+)\.fromCharCode\s*\(([^)]+)\)/g;
161
+ let match;
162
+ while ((match = direct.exec(code)) !== null) {
115
163
  const args = match[1].split(',').filter(a => /^\s*\d+\s*$/.test(a));
116
164
  if (args.length > maxArgs) maxArgs = args.length;
117
165
  }
118
- if (maxArgs < 5) return null;
119
- return { name: 'fromCharCode', score: 7, detail: `String.fromCharCode with ${maxArgs} numeric args` };
166
+
167
+ // Decimal char-code arrays of length >= 8 that look like ASCII text
168
+ // e.g. [101,118,97,108] -> "eval"
169
+ const arrRe = /\[\s*((?:\d{1,3}\s*,\s*){7,}\d{1,3})\s*\]/g;
170
+ let arrMatch;
171
+ let maxArr = 0;
172
+ while ((arrMatch = arrRe.exec(code)) !== null) {
173
+ const nums = arrMatch[1].split(',')
174
+ .map(s => parseInt(s.trim(), 10))
175
+ .filter(n => !Number.isNaN(n));
176
+ // ASCII printable range — typical for char-code payloads
177
+ const printable = nums.filter(n => n >= 32 && n <= 126).length;
178
+ if (printable / nums.length >= 0.9 && nums.length > maxArr) maxArr = nums.length;
179
+ }
180
+
181
+ if (maxArgs >= 5) {
182
+ return { name: 'fromCharCode', score: 7, detail: `fromCharCode with ${maxArgs} numeric args` };
183
+ }
184
+ if (maxArr >= 16) {
185
+ // Treat large printable-ascii decimal arrays as equivalent to fromCharCode obfuscation
186
+ return { name: 'fromCharCode', score: 7, detail: `decimal char-code array of length ${maxArr}` };
187
+ }
188
+ return null;
120
189
  }
121
190
 
122
191
  /**
123
- * Detect base64 decode combined with eval-like execution.
192
+ * Detect base64 / hex decoding combined with eval-like execution.
124
193
  * @param {string} code
125
194
  * @returns {Finding|null}
126
195
  */
127
196
  function checkBase64Exec(code) {
128
197
  const hasBase64 = /atob\s*\(|Buffer\.from\s*\([^)]*,\s*['"]base64['"]\)/.test(code);
129
- const hasExec = /eval\s*\(|new\s+Function\s*\(|\.exec\s*\(/.test(code);
130
- if (!hasBase64) return null;
131
- if (hasBase64 && !hasExec) {
132
- return { name: 'base64-decode', score: 3, detail: 'Base64 decode found — verify usage' };
198
+ const hasHexDecode = /Buffer\.from\s*\([^)]*,\s*['"]hex['"]\)/.test(code);
199
+ const hasExec = /\beval\s*\(|new\s+Function\s*\(|\.exec\s*\(|\(\s*0\s*,\s*eval\s*\)\s*\(/.test(code);
200
+ if (!hasBase64 && !hasHexDecode) return null;
201
+ if (!hasExec) {
202
+ const kind = hasBase64 ? 'Base64' : 'Hex';
203
+ return { name: 'encoded-decode', score: 3, detail: `${kind} decode found — verify usage` };
133
204
  }
134
- return { name: 'base64-decode+exec', score: 8, detail: 'Base64 decode with code execution found' };
205
+ const kind = hasBase64 ? 'Base64' : 'Hex';
206
+ return { name: 'encoded-decode+exec', score: 8, detail: `${kind} decode with code execution found` };
135
207
  }
136
208
 
137
209
  /**
138
- * Detect child_process / shell execution patterns.
210
+ * Detect child_process / shell execution patterns, including string-concatenated
211
+ * `require('child' + '_process')` and access via require.call etc.
139
212
  * @param {string} code
140
213
  * @returns {Finding|null}
141
214
  */
142
215
  function checkChildProcess(code) {
143
216
  const patterns = [
144
217
  /require\s*\(\s*['"]child_process['"]\s*\)/,
218
+ // node:-prefixed import
219
+ /require\s*\(\s*['"]node:child_process['"]\s*\)/,
220
+ // String-concatenation bypass: require('child'+'_process'), require(\`child${''}_process\`)
221
+ /require\s*\(\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`])+\s*\)/,
222
+ // Dynamic require with computed key — flag for review
223
+ /require\s*\(\s*[a-zA-Z_$][\w$]*\s*\[/,
145
224
  /\bexec\s*\(/,
146
225
  /\bspawn\s*\(/,
147
226
  /\bexecSync\s*\(/,
148
227
  /\bspawnSync\s*\(/,
149
228
  /\bexecFile\s*\(/,
229
+ /\bexecFileSync\s*\(/,
230
+ /\bfork\s*\(/,
231
+ // Worker threads can host eval-equivalent execution
232
+ /require\s*\(\s*['"]worker_threads['"]\s*\)/,
233
+ /new\s+Worker\s*\(/,
150
234
  ];
151
235
  const matched = patterns.filter(p => p.test(code));
152
236
  if (matched.length === 0) return null;
153
- return { name: 'child-process', score: 5, detail: `Shell execution found (${matched.length} pattern(s))` };
237
+ return { name: 'child-process', score: 5, detail: `Shell/process execution found (${matched.length} pattern(s))` };
154
238
  }
155
239
 
156
240
  /**
@@ -192,15 +276,68 @@ function checkNetworkCalls(code) {
192
276
  /require\s*\(\s*['"]https?['"]\s*\)/,
193
277
  /require\s*\(\s*['"]net['"]\s*\)/,
194
278
  /require\s*\(\s*['"]dns['"]\s*\)/,
195
- /fetch\s*\(/,
279
+ /require\s*\(\s*['"]tls['"]\s*\)/,
280
+ /require\s*\(\s*['"]dgram['"]\s*\)/,
281
+ /require\s*\(\s*['"]http2['"]\s*\)/,
282
+ /\bfetch\s*\(/,
196
283
  /XMLHttpRequest/,
197
284
  /\.request\s*\(/,
285
+ // node:-prefixed imports (Node 16+)
286
+ /require\s*\(\s*['"]node:(?:https?|net|dns|tls|dgram|http2)['"]\s*\)/,
287
+ // Dynamic import of these modules
288
+ /import\s*\(\s*['"](?:node:)?(?:https?|net|dns|tls|dgram|http2)['"]\s*\)/,
198
289
  ];
199
290
  const matched = patterns.filter(p => p.test(code));
200
291
  if (matched.length === 0) return null;
201
292
  return { name: 'network-call', score: 4, detail: `Network call found (${matched.length} pattern(s))` };
202
293
  }
203
294
 
295
+ /**
296
+ * Detect filesystem manipulation (potential backdoor installation).
297
+ * @param {string} code
298
+ * @returns {Finding|null}
299
+ */
300
+ function checkFilesystemManipulation(code) {
301
+ const writePatterns = [
302
+ /fs\.write(?:File)?(?:Sync)?\s*\(/,
303
+ /fs\.append(?:File)?(?:Sync)?\s*\(/,
304
+ /fs\.create(?:WriteStream)?\s*\(/,
305
+ /\.pipe\s*\(/,
306
+ ];
307
+ const permissionPatterns = [
308
+ /fs\.chmod(?:Sync)?\s*\(/,
309
+ /fs\.chown(?:Sync)?\s*\(/,
310
+ /fs\.access(?:Sync)?\s*\(/,
311
+ ];
312
+ const linkPatterns = [
313
+ /fs\.symlink(?:Sync)?\s*\(/,
314
+ /fs\.link(?:Sync)?\s*\(/,
315
+ ];
316
+
317
+ const writeMatches = writePatterns.filter(p => p.test(code)).length;
318
+ const permMatches = permissionPatterns.filter(p => p.test(code)).length;
319
+ const linkMatches = linkPatterns.filter(p => p.test(code)).length;
320
+
321
+ if (writeMatches === 0 && permMatches === 0 && linkMatches === 0) return null;
322
+
323
+ const details = [];
324
+ if (writeMatches > 0) details.push(`${writeMatches} write operation(s)`);
325
+ if (permMatches > 0) details.push(`${permMatches} permission change(s)`);
326
+ if (linkMatches > 0) details.push(`${linkMatches} symlink operation(s)`);
327
+
328
+ // Score 3-4 based on variety of operations
329
+ let score = 3;
330
+ if ((writeMatches > 0 ? 1 : 0) + (permMatches > 0 ? 1 : 0) + (linkMatches > 0 ? 1 : 0) >= 2) {
331
+ score = 4;
332
+ }
333
+
334
+ return {
335
+ name: 'filesystem-manipulation',
336
+ score,
337
+ detail: details.join(', ')
338
+ };
339
+ }
340
+
204
341
  // ─── Entropy helper ──────────────────────────────────────────────────────────
205
342
 
206
343
  function shannonEntropy(str) {
@@ -228,11 +365,13 @@ const CHECKS = [
228
365
  checkHexArray,
229
366
  checkProcessEnv,
230
367
  checkNetworkCalls,
368
+ checkFilesystemManipulation,
231
369
  ];
232
370
 
233
371
  /**
234
372
  * Run all checks against a code string.
235
- * For large files, analyzes multiple chunks and aggregates results.
373
+ * For large files, uses a sliding window (50% overlap) so payloads cannot
374
+ * hide in the gaps between fixed start/middle/end chunks.
236
375
  * @param {string} code
237
376
  * @param {object} config { blockScore, warnScore }
238
377
  * @returns {{ score: number, findings: Finding[], verdict: 'BLOCK'|'WARN'|'OK' }}
@@ -242,14 +381,18 @@ function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
242
381
  return { score: 0, findings: [], verdict: 'OK' };
243
382
  }
244
383
 
245
- // For large files, analyze chunks and take worst results
384
+ // For large files, slide a window across the entire content. With a 500KB
385
+ // window and 250KB stride, every byte appears in at least one window — so a
386
+ // payload at any offset is guaranteed to be analyzed in a single contiguous
387
+ // chunk.
246
388
  const chunks = [];
247
389
  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));
390
+ let start = 0;
391
+ while (start < code.length) {
392
+ chunks.push(code.slice(start, start + MAX_CODE_SIZE));
393
+ if (start + MAX_CODE_SIZE >= code.length) break;
394
+ start += CHUNK_STRIDE;
395
+ }
253
396
  } else {
254
397
  chunks.push(code);
255
398
  }
@@ -297,4 +440,5 @@ module.exports = {
297
440
  checkHexArray,
298
441
  checkProcessEnv,
299
442
  checkNetworkCalls,
443
+ checkFilesystemManipulation,
300
444
  };