np-audit 1.5.1 → 2.1.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/README.md +117 -182
- package/package.json +1 -1
- package/src/cli.js +18 -3
- package/src/commands/alias.js +30 -11
- package/src/commands/ci.js +5 -2
- package/src/commands/install.js +5 -2
- package/src/commands/scan.js +5 -2
- package/src/core/detector.js +8 -28
- package/src/core/scanner.js +173 -42
- package/src/marshallers/base.js +18 -0
- package/src/marshallers/base64Exec.js +23 -0
- package/src/marshallers/childProcess.js +32 -0
- package/src/marshallers/cve.js +116 -0
- package/src/marshallers/eval.js +30 -0
- package/src/marshallers/filesystemManipulation.js +47 -0
- package/src/marshallers/fromCharCode.js +40 -0
- package/src/marshallers/hexArray.js +21 -0
- package/src/marshallers/hexEscapes.js +27 -0
- package/src/marshallers/highEntropy.js +70 -0
- package/src/marshallers/index.js +25 -0
- package/src/marshallers/networkCalls.js +30 -0
- package/src/marshallers/obfuscatorIo.js +22 -0
- package/src/marshallers/processEnv.js +17 -0
- package/src/marshallers/runtimeDownload.js +73 -0
- package/src/marshallers/vscodeTasks.js +29 -0
- package/src/utils/config.js +2 -0
- package/src/utils/entropy.js +15 -0
- package/src/utils/fetcher.js +5 -4
- package/src/utils/output.js +39 -9
package/src/core/detector.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { shannonEntropy } = require('../utils/entropy');
|
|
4
|
+
|
|
3
5
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
4
6
|
|
|
5
7
|
const MAX_CODE_SIZE = 500000; // 500KB - chunk larger files
|
|
@@ -338,36 +340,10 @@ function checkFilesystemManipulation(code) {
|
|
|
338
340
|
};
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
// ─── Entropy helper
|
|
342
|
-
|
|
343
|
-
function shannonEntropy(str) {
|
|
344
|
-
if (!str || str.length === 0) return 0;
|
|
345
|
-
const freq = {};
|
|
346
|
-
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
347
|
-
let entropy = 0;
|
|
348
|
-
for (const count of Object.values(freq)) {
|
|
349
|
-
const p = count / str.length;
|
|
350
|
-
entropy -= p * Math.log2(p);
|
|
351
|
-
}
|
|
352
|
-
return entropy;
|
|
353
|
-
}
|
|
343
|
+
// ─── Entropy helper (re-exported from utils/entropy.js) ─────────────────────
|
|
354
344
|
|
|
355
345
|
// ─── Main detection function ─────────────────────────────────────────────────
|
|
356
346
|
|
|
357
|
-
const CHECKS = [
|
|
358
|
-
checkEval,
|
|
359
|
-
checkObfuscatorIo,
|
|
360
|
-
checkHighEntropy,
|
|
361
|
-
checkHexEscapes,
|
|
362
|
-
checkFromCharCode,
|
|
363
|
-
checkBase64Exec,
|
|
364
|
-
checkChildProcess,
|
|
365
|
-
checkHexArray,
|
|
366
|
-
checkProcessEnv,
|
|
367
|
-
checkNetworkCalls,
|
|
368
|
-
checkFilesystemManipulation,
|
|
369
|
-
];
|
|
370
|
-
|
|
371
347
|
/**
|
|
372
348
|
* Run all checks against a code string.
|
|
373
349
|
* For large files, uses a sliding window (50% overlap) so payloads cannot
|
|
@@ -399,8 +375,12 @@ function detectObfuscation(code, config = { blockScore: 50, warnScore: 20 }) {
|
|
|
399
375
|
|
|
400
376
|
const allFindings = new Map(); // Dedupe by name, keep highest score
|
|
401
377
|
|
|
378
|
+
// Combine inline checks with marshaller registry
|
|
379
|
+
const { getStaticMarshallers } = require('../marshallers');
|
|
380
|
+
const allChecks = getStaticMarshallers().map(m => m.check.bind(m));
|
|
381
|
+
|
|
402
382
|
for (const chunk of chunks) {
|
|
403
|
-
for (const check of
|
|
383
|
+
for (const check of allChecks) {
|
|
404
384
|
const result = check(chunk);
|
|
405
385
|
if (result) {
|
|
406
386
|
const existing = allFindings.get(result.name);
|
package/src/core/scanner.js
CHANGED
|
@@ -8,6 +8,7 @@ const { parseTarGz, extractFile, getPackageJson } = require('../utils/tar
|
|
|
8
8
|
const { detectObfuscation } = require('./detector');
|
|
9
9
|
const { walkRequires, MAX_FILES_PER_PACKAGE, MAX_TOTAL_BYTES } = require('./requireWalker');
|
|
10
10
|
const { parseCommand } = require('../utils/command');
|
|
11
|
+
const { getPackageMarshallers } = require('../marshallers');
|
|
11
12
|
const output = require('../utils/output');
|
|
12
13
|
|
|
13
14
|
// Lifecycle scripts that npm executes during install. The original tool only
|
|
@@ -73,10 +74,7 @@ async function scan(opts) {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
//
|
|
77
|
-
let skippedCount = 0;
|
|
78
|
-
|
|
79
|
-
// Apply skip filters
|
|
77
|
+
// Apply user skip filters (scopes, packages, dev)
|
|
80
78
|
packages = packages.filter(pkg => {
|
|
81
79
|
if (noDev && pkg.dev) return false;
|
|
82
80
|
if (pkg.inBundle || pkg.link) return false;
|
|
@@ -86,15 +84,15 @@ async function scan(opts) {
|
|
|
86
84
|
if (pkg.name.startsWith(scope + '/') || pkg.name === scope) return false;
|
|
87
85
|
}
|
|
88
86
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// All non-skipped packages are eligible for package-level marshallers (CVE)
|
|
91
|
+
const allPackages = packages;
|
|
92
|
+
|
|
93
|
+
// Filter to only packages with lifecycle scripts for code analysis
|
|
94
|
+
packages = packages.filter(pkg => {
|
|
95
|
+
if (explicitPackageNames.has(pkg.name)) return true;
|
|
98
96
|
if (lockfileVersion >= 2 && pkg.hasInstallScript === false) return false;
|
|
99
97
|
return true;
|
|
100
98
|
});
|
|
@@ -106,23 +104,52 @@ async function scan(opts) {
|
|
|
106
104
|
return scanPackage(pkg, cwd, config, verbose);
|
|
107
105
|
});
|
|
108
106
|
|
|
107
|
+
// Run package-level marshallers (CVE checks) on ALL packages, not just those with scripts
|
|
108
|
+
if (config.checkVulnerabilities) {
|
|
109
|
+
const packageMarshallers = getPackageMarshallers();
|
|
110
|
+
const cveResults = await mapWithConcurrency(allPackages, config.parallelFetches, async (pkg) => {
|
|
111
|
+
for (const marshaller of packageMarshallers) {
|
|
112
|
+
const finding = await marshaller.checkPackage(pkg, config);
|
|
113
|
+
if (finding) return { pkg, finding };
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const cveResult of cveResults) {
|
|
119
|
+
if (!cveResult) continue;
|
|
120
|
+
const { pkg, finding } = cveResult;
|
|
121
|
+
const existing = results.find(r => r && r.pkg && r.pkg.name === pkg.name);
|
|
122
|
+
if (existing) {
|
|
123
|
+
existing.findings.push(finding);
|
|
124
|
+
if (finding.score > existing.score) {
|
|
125
|
+
existing.score = finding.score;
|
|
126
|
+
existing.verdict = verdictFromScore(finding.score, config);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
results.push({
|
|
130
|
+
pkg,
|
|
131
|
+
scripts: [],
|
|
132
|
+
score: finding.score,
|
|
133
|
+
findings: [finding],
|
|
134
|
+
verdict: verdictFromScore(finding.score, config),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
const scanned = results.filter(Boolean);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Optionally scan the *current project's own* lifecycle scripts. This is
|
|
114
|
-
// off by default to avoid surprising users — `npa` is a drop-in replacement
|
|
115
|
-
// for `npm install` and most projects' own postinstall scripts are
|
|
116
|
-
// intentionally local. Set `scanSelf: true` in .npmauditor.json (or pass
|
|
117
|
-
// --scan-self) to opt in. Useful for CI on third-party PRs.
|
|
141
|
+
|
|
142
|
+
// Optionally scan the *current project's own* lifecycle scripts.
|
|
118
143
|
if (config.scanSelf) {
|
|
119
144
|
const selfResult = scanCwdProject(cwd, config);
|
|
120
145
|
if (selfResult) scanned.unshift(selfResult);
|
|
121
|
-
else skippedCount++;
|
|
122
146
|
}
|
|
123
147
|
|
|
124
|
-
//
|
|
125
|
-
|
|
148
|
+
// Scan IDE/tool config files that can auto-execute code
|
|
149
|
+
const ideResults = scanIdeConfigs(cwd, config);
|
|
150
|
+
scanned.push(...ideResults);
|
|
151
|
+
|
|
152
|
+
scanned.totalPackages = allPackages.length + ideResults.length;
|
|
126
153
|
return scanned;
|
|
127
154
|
}
|
|
128
155
|
|
|
@@ -155,6 +182,78 @@ function scanCwdProject(cwd, config) {
|
|
|
155
182
|
return analyzeScriptsLocalFromDir(pkg, pkgJson, cwd, config);
|
|
156
183
|
}
|
|
157
184
|
|
|
185
|
+
const IDE_CONFIG_FILES = [
|
|
186
|
+
'.vscode/tasks.json',
|
|
187
|
+
'.vscode/settings.json',
|
|
188
|
+
'.vscode/launch.json',
|
|
189
|
+
'.claude/settings.json',
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function scanIdeConfigs(cwd, config) {
|
|
193
|
+
const results = [];
|
|
194
|
+
|
|
195
|
+
for (const relPath of IDE_CONFIG_FILES) {
|
|
196
|
+
const fullPath = path.join(cwd, relPath);
|
|
197
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
198
|
+
|
|
199
|
+
let code;
|
|
200
|
+
try { code = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
201
|
+
|
|
202
|
+
const result = detectObfuscation(code, config);
|
|
203
|
+
if (result.score === 0) continue;
|
|
204
|
+
|
|
205
|
+
results.push({
|
|
206
|
+
pkg: { name: relPath, version: '', self: true },
|
|
207
|
+
scripts: [{ lifecycle: 'ide-config', file: relPath, code, ...result }],
|
|
208
|
+
score: result.score,
|
|
209
|
+
findings: result.findings,
|
|
210
|
+
verdict: result.verdict,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Also scan any files referenced by commands in tasks.json
|
|
214
|
+
if (relPath.endsWith('tasks.json')) {
|
|
215
|
+
const referenced = extractReferencedScripts(code, cwd);
|
|
216
|
+
for (const { file, scriptCode } of referenced) {
|
|
217
|
+
const scriptResult = detectObfuscation(scriptCode, config);
|
|
218
|
+
if (scriptResult.score > 0) {
|
|
219
|
+
const existing = results.find(r => r.pkg.name === relPath);
|
|
220
|
+
if (existing) {
|
|
221
|
+
existing.scripts.push({ lifecycle: 'task-script', file, code: scriptCode, ...scriptResult });
|
|
222
|
+
existing.findings.push(...scriptResult.findings);
|
|
223
|
+
if (scriptResult.score > existing.score) {
|
|
224
|
+
existing.score = scriptResult.score;
|
|
225
|
+
existing.verdict = verdictFromScore(scriptResult.score, config);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function extractReferencedScripts(tasksJson, cwd) {
|
|
237
|
+
const scripts = [];
|
|
238
|
+
try {
|
|
239
|
+
const tasks = JSON.parse(tasksJson);
|
|
240
|
+
for (const task of tasks.tasks || []) {
|
|
241
|
+
if (!task.command) continue;
|
|
242
|
+
// Extract file paths from commands like "node .claude/setup.mjs"
|
|
243
|
+
const match = task.command.match(/(?:node|bun|deno|sh|bash|python)\s+([^\s]+)/);
|
|
244
|
+
if (match) {
|
|
245
|
+
const scriptPath = path.join(cwd, match[1]);
|
|
246
|
+
if (fs.existsSync(scriptPath)) {
|
|
247
|
+
try {
|
|
248
|
+
scripts.push({ file: match[1], scriptCode: fs.readFileSync(scriptPath, 'utf8') });
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {}
|
|
254
|
+
return scripts;
|
|
255
|
+
}
|
|
256
|
+
|
|
158
257
|
/**
|
|
159
258
|
* Analyze a package's lifecycle scripts using a directory root as the
|
|
160
259
|
* filesystem base. Used for both node_modules packages and the CWD itself.
|
|
@@ -647,24 +746,55 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
647
746
|
const packages = [];
|
|
648
747
|
const seen = new Set();
|
|
649
748
|
|
|
650
|
-
function collectDeps(deps) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
749
|
+
async function collectDeps(deps, recurse) {
|
|
750
|
+
const queue = Object.entries(deps || {}).filter(([depName]) => !seen.has(depName));
|
|
751
|
+
// Mark all as seen first to avoid duplicate fetches
|
|
752
|
+
for (const [depName] of queue) seen.add(depName);
|
|
753
|
+
|
|
754
|
+
const resolutions = await mapWithConcurrency(queue, config.parallelFetches, async ([depName, range]) => {
|
|
655
755
|
const exactVersion = extractSemver(range);
|
|
656
|
-
if (!exactVersion)
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
756
|
+
if (!exactVersion) return null;
|
|
757
|
+
|
|
758
|
+
let depScripts = false;
|
|
759
|
+
let depDeps = null;
|
|
760
|
+
let depTarball = buildTarballUrl(depName, exactVersion, config.registry);
|
|
761
|
+
let depIntegrity = '';
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
const encodedDep = depName.startsWith('@') ? `@${encodeURIComponent(depName.slice(1))}` : encodeURIComponent(depName);
|
|
765
|
+
const depMeta = await fetchJSON(`${config.registry}/${encodedDep}`, { timeout: config.timeout });
|
|
766
|
+
const depData = depMeta.versions && depMeta.versions[exactVersion];
|
|
767
|
+
if (depData) {
|
|
768
|
+
depScripts = !!(depData.scripts &&
|
|
769
|
+
(depData.scripts.preinstall || depData.scripts.postinstall || depData.scripts.install));
|
|
770
|
+
depTarball = depData.dist && depData.dist.tarball || depTarball;
|
|
771
|
+
depIntegrity = depData.dist && depData.dist.integrity || '';
|
|
772
|
+
depDeps = depData.dependencies;
|
|
773
|
+
}
|
|
774
|
+
} catch {
|
|
775
|
+
// Failed to fetch dep metadata — add with what we have
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
pkg: {
|
|
780
|
+
name: depName,
|
|
781
|
+
version: exactVersion,
|
|
782
|
+
resolved: depTarball,
|
|
783
|
+
integrity: depIntegrity,
|
|
784
|
+
hasInstallScript: depScripts,
|
|
785
|
+
dev: false,
|
|
786
|
+
optional: false,
|
|
787
|
+
inBundle: false,
|
|
788
|
+
link: false,
|
|
789
|
+
},
|
|
790
|
+
depDeps,
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
for (const r of resolutions) {
|
|
795
|
+
if (!r) continue;
|
|
796
|
+
packages.push(r.pkg);
|
|
797
|
+
if (recurse && r.depDeps) await collectDeps(r.depDeps, true);
|
|
668
798
|
}
|
|
669
799
|
}
|
|
670
800
|
|
|
@@ -682,7 +812,8 @@ async function resolveSinglePackage(packageSpec, config) {
|
|
|
682
812
|
link: false,
|
|
683
813
|
});
|
|
684
814
|
|
|
685
|
-
|
|
815
|
+
seen.add(name);
|
|
816
|
+
await collectDeps(versionData.dependencies, !!config.deepResolve);
|
|
686
817
|
|
|
687
818
|
return packages;
|
|
688
819
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class Marshaller {
|
|
4
|
+
constructor(name, title) {
|
|
5
|
+
this.name = name;
|
|
6
|
+
this.title = title;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
check(code) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async checkPackage(pkg, config) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { Marshaller };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class Base64ExecMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('encoded-decode', 'Encoded decode + execution detection');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
const hasBase64 = /atob\s*\(|Buffer\.from\s*\([^)]*,\s*['"]base64['"]\)/.test(code);
|
|
12
|
+
const hasHexDecode = /Buffer\.from\s*\([^)]*,\s*['"]hex['"]\)/.test(code);
|
|
13
|
+
const hasExec = /\beval\s*\(|new\s+Function\s*\(|\.exec\s*\(|\(\s*0\s*,\s*eval\s*\)\s*\(/.test(code);
|
|
14
|
+
if (!hasBase64 && !hasHexDecode) return null;
|
|
15
|
+
const kind = hasBase64 ? 'Base64' : 'Hex';
|
|
16
|
+
if (!hasExec) {
|
|
17
|
+
return { name: 'encoded-decode', score: 3, detail: `${kind} decode found — verify usage` };
|
|
18
|
+
}
|
|
19
|
+
return { name: 'encoded-decode+exec', score: 8, detail: `${kind} decode with code execution found` };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = new Base64ExecMarshaller();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class ChildProcessMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('child-process', 'Shell / process execution detection');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
const patterns = [
|
|
12
|
+
/require\s*\(\s*['"]child_process['"]\s*\)/,
|
|
13
|
+
/require\s*\(\s*['"]node:child_process['"]\s*\)/,
|
|
14
|
+
/require\s*\(\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`])+\s*\)/,
|
|
15
|
+
/require\s*\(\s*[a-zA-Z_$][\w$]*\s*\[/,
|
|
16
|
+
/\bexec\s*\(/,
|
|
17
|
+
/\bspawn\s*\(/,
|
|
18
|
+
/\bexecSync\s*\(/,
|
|
19
|
+
/\bspawnSync\s*\(/,
|
|
20
|
+
/\bexecFile\s*\(/,
|
|
21
|
+
/\bexecFileSync\s*\(/,
|
|
22
|
+
/\bfork\s*\(/,
|
|
23
|
+
/require\s*\(\s*['"]worker_threads['"]\s*\)/,
|
|
24
|
+
/new\s+Worker\s*\(/,
|
|
25
|
+
];
|
|
26
|
+
const matched = patterns.filter(p => p.test(code));
|
|
27
|
+
if (matched.length === 0) return null;
|
|
28
|
+
return { name: this.name, score: 5, detail: `Shell/process execution found (${matched.length} pattern(s))` };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = new ChildProcessMarshaller();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { Marshaller } = require('./base');
|
|
7
|
+
|
|
8
|
+
const SNYK_CONFIG_FILE = '.config/configstore/snyk.json';
|
|
9
|
+
|
|
10
|
+
class CveMarshaller extends Marshaller {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('known-vulnerability', 'Known vulnerability check (Snyk / OSV.dev)');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async checkPackage(pkg, config) {
|
|
16
|
+
if (!pkg.name || !pkg.version) return null;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const token = this.getSnykToken();
|
|
20
|
+
const timeout = Math.min(config.timeout || 10000, 10000);
|
|
21
|
+
const result = token
|
|
22
|
+
? await this.querySnyk(pkg.name, pkg.version, token, timeout)
|
|
23
|
+
: await this.queryOsv(pkg.name, pkg.version, timeout);
|
|
24
|
+
|
|
25
|
+
if (result.issuesCount === 0) return null;
|
|
26
|
+
|
|
27
|
+
if (result.isMalicious) {
|
|
28
|
+
return {
|
|
29
|
+
name: this.name,
|
|
30
|
+
score: 80,
|
|
31
|
+
detail: `Malicious package detected — ${result.issuesCount} advisory(ies) found`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Non-malicious CVEs are informational (WARN, never BLOCK)
|
|
36
|
+
let score = 4;
|
|
37
|
+
if (result.issuesCount >= 10) score = 6;
|
|
38
|
+
else if (result.issuesCount >= 5) score = 5;
|
|
39
|
+
|
|
40
|
+
const source = token ? 'Snyk' : 'OSV.dev';
|
|
41
|
+
return {
|
|
42
|
+
name: this.name,
|
|
43
|
+
score,
|
|
44
|
+
detail: `${result.issuesCount} known vulnerability(ies) via ${source}`,
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSnykToken() {
|
|
52
|
+
const token = process.env.SNYK_API_TOKEN || process.env.SNYK_TOKEN;
|
|
53
|
+
if (token) return token;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const configPath = path.join(os.homedir(), SNYK_CONFIG_FILE);
|
|
57
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
58
|
+
if (cfg && cfg.api) return cfg.api;
|
|
59
|
+
} catch {
|
|
60
|
+
// No Snyk config
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async querySnyk(packageName, packageVersion, token, timeout) {
|
|
67
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
68
|
+
const apiUrl = process.env.SNYK_API_URL || process.env.SNYK_API || 'https://snyk.io/api/v1/vuln/npm';
|
|
69
|
+
const url = `${apiUrl}/${encodeURIComponent(packageName + '@' + packageVersion)}`;
|
|
70
|
+
|
|
71
|
+
const data = await fetchJSON(url, {
|
|
72
|
+
timeout,
|
|
73
|
+
headers: { Authorization: `token ${token}` },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (data && data.vulnerabilities) {
|
|
77
|
+
const isMalicious = data.vulnerabilities.some(v => v.title === 'Malicious Package');
|
|
78
|
+
return { issuesCount: data.vulnerabilities.length, isMalicious };
|
|
79
|
+
}
|
|
80
|
+
return { issuesCount: 0, isMalicious: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async queryOsv(packageName, packageVersion, timeout) {
|
|
84
|
+
const { fetchJSON } = require('../utils/fetcher');
|
|
85
|
+
const url = 'https://api.osv.dev/v1/query';
|
|
86
|
+
|
|
87
|
+
const body = JSON.stringify({
|
|
88
|
+
version: packageVersion,
|
|
89
|
+
package: { name: packageName, ecosystem: 'npm' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const data = await fetchJSON(url, {
|
|
93
|
+
timeout,
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (data && data.vulns && data.vulns.length > 0) {
|
|
100
|
+
const isMalicious = data.vulns.some(vuln => {
|
|
101
|
+
const ds = vuln.database_specific;
|
|
102
|
+
if (ds && ds['malicious-packages-origins'] && Array.isArray(ds['malicious-packages-origins'])) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (typeof vuln.summary === 'string' && vuln.summary.toLowerCase().startsWith('malicious')) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
110
|
+
return { issuesCount: data.vulns.length, isMalicious };
|
|
111
|
+
}
|
|
112
|
+
return { issuesCount: 0, isMalicious: false };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = new CveMarshaller();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class EvalMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('eval/dynamic-exec', 'Eval / dynamic code execution');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
const patterns = [
|
|
12
|
+
/\beval\s*\(/,
|
|
13
|
+
/new\s+Function\s*\(/,
|
|
14
|
+
/vm\.runInThisContext\s*\(/,
|
|
15
|
+
/vm\.runInNewContext\s*\(/,
|
|
16
|
+
/vm\.Script\s*\(/,
|
|
17
|
+
/\(\s*0\s*,\s*eval\s*\)\s*\(/,
|
|
18
|
+
/(?:global|globalThis|window|self|this)\s*\[\s*['"`](?:eval|Function)['"`]\s*\]\s*\(/,
|
|
19
|
+
/(?:global|globalThis|window|self|this)\s*\[\s*['"`][^'"`]*['"`](?:\s*\+\s*['"`][^'"`]*['"`]){1,}\s*\]\s*\(/,
|
|
20
|
+
/\.constructor\s*\.\s*constructor\s*\(/,
|
|
21
|
+
/\b(?:setTimeout|setInterval)\s*\(\s*['"`]/,
|
|
22
|
+
/require\s*\(\s*['"]vm['"]\s*\)/,
|
|
23
|
+
];
|
|
24
|
+
const matched = patterns.filter(p => p.test(code));
|
|
25
|
+
if (matched.length === 0) return null;
|
|
26
|
+
return { name: this.name, score: 8, detail: `eval-like call found (${matched.length} pattern(s))` };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = new EvalMarshaller();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class FilesystemManipulationMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('filesystem-manipulation', 'Filesystem manipulation detection');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
const writePatterns = [
|
|
12
|
+
/fs\.write(?:File)?(?:Sync)?\s*\(/,
|
|
13
|
+
/fs\.append(?:File)?(?:Sync)?\s*\(/,
|
|
14
|
+
/fs\.create(?:WriteStream)?\s*\(/,
|
|
15
|
+
/\.pipe\s*\(/,
|
|
16
|
+
];
|
|
17
|
+
const permissionPatterns = [
|
|
18
|
+
/fs\.chmod(?:Sync)?\s*\(/,
|
|
19
|
+
/fs\.chown(?:Sync)?\s*\(/,
|
|
20
|
+
/fs\.access(?:Sync)?\s*\(/,
|
|
21
|
+
];
|
|
22
|
+
const linkPatterns = [
|
|
23
|
+
/fs\.symlink(?:Sync)?\s*\(/,
|
|
24
|
+
/fs\.link(?:Sync)?\s*\(/,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const writeMatches = writePatterns.filter(p => p.test(code)).length;
|
|
28
|
+
const permMatches = permissionPatterns.filter(p => p.test(code)).length;
|
|
29
|
+
const linkMatches = linkPatterns.filter(p => p.test(code)).length;
|
|
30
|
+
|
|
31
|
+
if (writeMatches === 0 && permMatches === 0 && linkMatches === 0) return null;
|
|
32
|
+
|
|
33
|
+
const details = [];
|
|
34
|
+
if (writeMatches > 0) details.push(`${writeMatches} write operation(s)`);
|
|
35
|
+
if (permMatches > 0) details.push(`${permMatches} permission change(s)`);
|
|
36
|
+
if (linkMatches > 0) details.push(`${linkMatches} symlink operation(s)`);
|
|
37
|
+
|
|
38
|
+
let score = 3;
|
|
39
|
+
if ((writeMatches > 0 ? 1 : 0) + (permMatches > 0 ? 1 : 0) + (linkMatches > 0 ? 1 : 0) >= 2) {
|
|
40
|
+
score = 4;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { name: this.name, score, detail: details.join(', ') };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = new FilesystemManipulationMarshaller();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class FromCharCodeMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('fromCharCode', 'String.fromCharCode obfuscation detection');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
let maxArgs = 0;
|
|
12
|
+
const direct = /(?:String|[\w$]+)\.fromCharCode\s*\(([^)]+)\)/g;
|
|
13
|
+
let match;
|
|
14
|
+
while ((match = direct.exec(code)) !== null) {
|
|
15
|
+
const args = match[1].split(',').filter(a => /^\s*\d+\s*$/.test(a));
|
|
16
|
+
if (args.length > maxArgs) maxArgs = args.length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const arrRe = /\[\s*((?:\d{1,3}\s*,\s*){7,}\d{1,3})\s*\]/g;
|
|
20
|
+
let arrMatch;
|
|
21
|
+
let maxArr = 0;
|
|
22
|
+
while ((arrMatch = arrRe.exec(code)) !== null) {
|
|
23
|
+
const nums = arrMatch[1].split(',')
|
|
24
|
+
.map(s => parseInt(s.trim(), 10))
|
|
25
|
+
.filter(n => !Number.isNaN(n));
|
|
26
|
+
const printable = nums.filter(n => n >= 32 && n <= 126).length;
|
|
27
|
+
if (printable / nums.length >= 0.9 && nums.length > maxArr) maxArr = nums.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (maxArgs >= 5) {
|
|
31
|
+
return { name: this.name, score: 7, detail: `fromCharCode with ${maxArgs} numeric args` };
|
|
32
|
+
}
|
|
33
|
+
if (maxArr >= 16) {
|
|
34
|
+
return { name: this.name, score: 7, detail: `decimal char-code array of length ${maxArr}` };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = new FromCharCodeMarshaller();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Marshaller } = require('./base');
|
|
4
|
+
|
|
5
|
+
class HexArrayMarshaller extends Marshaller {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('hex-array', 'Hex literal array detection');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
check(code) {
|
|
11
|
+
const hexLiterals = (code.match(/\b0x[0-9a-fA-F]+\b/g) || []).length;
|
|
12
|
+
if (hexLiterals < 20) return null;
|
|
13
|
+
let score = 7;
|
|
14
|
+
if (hexLiterals > 2000) score = 60;
|
|
15
|
+
else if (hexLiterals > 500) score = 40;
|
|
16
|
+
else if (hexLiterals > 100) score = 20;
|
|
17
|
+
return { name: this.name, score, detail: `${hexLiterals} hex literal values found` };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = new HexArrayMarshaller();
|