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 +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 +321 -54
- 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
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/np-audit)
|
|
4
4
|
[](https://www.npmjs.com/package/np-audit)
|
|
5
|
+
[](https://www.npmjs.com/package/np-audit)
|
|
5
6
|
[](https://github.com/KoblerS/np-audit/blob/main/LICENSE)
|
|
6
7
|
[](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
|
-
- **
|
|
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
|
|
47
|
-
|
|
48
|
-
| `eval()` / `new Function()`
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
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
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
| `
|
|
57
|
-
|
|
|
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
|
|
88
|
-
|
|
89
|
-
| `npa install [package]`
|
|
90
|
-
| `npa ci`
|
|
91
|
-
| `npa scan`
|
|
92
|
-
| `npa config get`
|
|
93
|
-
| `npa config set <key> <value>` | `npa c set` | Update a config value
|
|
94
|
-
| `npa alias`
|
|
95
|
-
| `npa alias --install`
|
|
96
|
-
| `npa alias --uninstall`
|
|
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
|
|
103
|
-
|
|
104
|
-
| `--
|
|
105
|
-
| `--json`
|
|
106
|
-
| `--no-dev`
|
|
107
|
-
| `--verbose` | —
|
|
108
|
-
| `--version` | —
|
|
109
|
-
| `--help`
|
|
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 `--
|
|
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 --
|
|
142
|
-
npa ci --
|
|
164
|
+
npa i --review # or: npa i -r
|
|
165
|
+
npa ci --review
|
|
143
166
|
```
|
|
144
167
|
|
|
145
168
|
```
|
|
146
|
-
npa --
|
|
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
|
|
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
|
|
204
|
-
[npa] Scan found issues. Run 'npa install --
|
|
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
|
|
216
|
-
|
|
217
|
-
| `blockScore`
|
|
218
|
-
| `warnScore`
|
|
219
|
-
| `registry`
|
|
220
|
-
| `timeout`
|
|
221
|
-
| `parallelFetches` | `5`
|
|
222
|
-
| `skipScopes`
|
|
223
|
-
| `skipPackages`
|
|
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`
|
|
232
|
-
| `1`
|
|
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. **
|
|
242
|
-
5. **
|
|
243
|
-
6. **
|
|
244
|
-
7. **
|
|
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
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 => {
|
package/src/core/detector.js
CHANGED
|
@@ -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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
96
|
-
|
|
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 (
|
|
100
|
-
else if (
|
|
101
|
-
else if (
|
|
102
|
-
|
|
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
|
-
|
|
112
|
-
let match;
|
|
158
|
+
// Direct (or property-access) call: String.fromCharCode(...) or anyObj.fromCharCode(...)
|
|
113
159
|
let maxArgs = 0;
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
130
|
-
|
|
131
|
-
if (hasBase64 && !
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
/
|
|
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,
|
|
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,
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
};
|