muaddib-scanner 2.5.11 → 2.5.12
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/logs/alerts/{2026-03-06T20-50-14-544-evil-pkg.json → 2026-03-07T16-18-04-719-evil-pkg.json} +1 -1
- package/logs/alerts/{2026-03-06T20-50-14-545-evil-pkg.json → 2026-03-07T16-18-04-720-evil-pkg.json} +1 -1
- package/logs/alerts/{2026-03-06T20-50-14-545-suspect-pkg.json → 2026-03-07T16-18-04-720-suspect-pkg.json} +1 -1
- package/logs/alerts/{2026-03-06T20-50-15-036-evil-pkg.json → 2026-03-07T16-18-05-033-evil-pkg.json} +1 -1
- package/logs/daily-reports/{2026-03-06.json → 2026-03-07.json} +5 -5
- package/package.json +1 -1
- package/src/ioc/yaml-loader.js +45 -4
- package/src/response/playbooks.js +20 -0
- package/src/rules/index.js +52 -0
- package/src/sarif.js +6 -1
- package/src/scanner/ast-detectors.js +221 -30
- package/src/scanner/ast.js +2 -0
- package/src/scanner/dataflow.js +64 -22
- package/src/scanner/module-graph.js +181 -1
- package/src/scoring.js +8 -1
- package/src/shared/download.js +1 -1
- package/src/utils.js +6 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"date": "2026-03-
|
|
3
|
-
"timestamp": "2026-03-
|
|
2
|
+
"date": "2026-03-07",
|
|
3
|
+
"timestamp": "2026-03-07T16:18:05.131Z",
|
|
4
4
|
"embed": {
|
|
5
5
|
"embeds": [
|
|
6
6
|
{
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"name": "Top Suspects",
|
|
37
|
-
"value": "1. **npm/test-dedup-detection-
|
|
37
|
+
"value": "1. **npm/test-dedup-detection-1772900284717@1.0.0** — 1 finding(s)",
|
|
38
38
|
"inline": false
|
|
39
39
|
}
|
|
40
40
|
],
|
|
41
41
|
"footer": {
|
|
42
|
-
"text": "MUAD'DIB - Daily summary | 2026-03-
|
|
42
|
+
"text": "MUAD'DIB - Daily summary | 2026-03-07 16:18:05 UTC"
|
|
43
43
|
},
|
|
44
|
-
"timestamp": "2026-03-
|
|
44
|
+
"timestamp": "2026-03-07T16:18:05.131Z"
|
|
45
45
|
}
|
|
46
46
|
]
|
|
47
47
|
},
|
package/package.json
CHANGED
package/src/ioc/yaml-loader.js
CHANGED
|
@@ -4,6 +4,31 @@ const yaml = require('js-yaml');
|
|
|
4
4
|
|
|
5
5
|
const IOCS_DIR = path.join(__dirname, '../../iocs');
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Read a YAML file with optional HMAC verification.
|
|
9
|
+
* If a sibling .hmac file exists, verify integrity. Warn on mismatch but still load
|
|
10
|
+
* (backward-compatible for pre-HMAC installs).
|
|
11
|
+
* Uses lazy require to avoid circular dependency with updater.js.
|
|
12
|
+
* @param {string} filePath - Path to the YAML file
|
|
13
|
+
* @returns {string} Raw YAML content
|
|
14
|
+
*/
|
|
15
|
+
function readVerifiedYAML(filePath) {
|
|
16
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
17
|
+
const hmacPath = filePath + '.hmac';
|
|
18
|
+
if (fs.existsSync(hmacPath)) {
|
|
19
|
+
try {
|
|
20
|
+
const { verifyIOCHMAC } = require('./updater.js');
|
|
21
|
+
const storedHmac = fs.readFileSync(hmacPath, 'utf8').trim();
|
|
22
|
+
if (!verifyIOCHMAC(content, storedHmac)) {
|
|
23
|
+
console.error(`[WARN] HMAC verification failed for ${path.basename(filePath)} — possible tampering`);
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error(`[WARN] Could not read HMAC file for ${path.basename(filePath)}: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return content;
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
function loadYAMLIOCs() {
|
|
8
33
|
const iocs = {
|
|
9
34
|
packages: [],
|
|
@@ -34,7 +59,7 @@ function loadPackagesYAML(filePath, iocs, seenPkgs) {
|
|
|
34
59
|
if (!fs.existsSync(filePath)) return;
|
|
35
60
|
|
|
36
61
|
try {
|
|
37
|
-
const data = yaml.load(
|
|
62
|
+
const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
|
|
38
63
|
if (data && data.packages) {
|
|
39
64
|
for (const p of data.packages) {
|
|
40
65
|
if (!p.name || typeof p.name !== 'string') continue;
|
|
@@ -64,7 +89,7 @@ function loadBuiltinYAML(filePath, iocs, seenPkgs, seenHashes, seenMarkers, seen
|
|
|
64
89
|
if (!fs.existsSync(filePath)) return;
|
|
65
90
|
|
|
66
91
|
try {
|
|
67
|
-
const data = yaml.load(
|
|
92
|
+
const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
|
|
68
93
|
|
|
69
94
|
// Packages
|
|
70
95
|
if (data && data.packages) {
|
|
@@ -150,7 +175,7 @@ function loadHashesYAML(filePath, iocs, seenHashes, seenMarkers, seenFiles) {
|
|
|
150
175
|
if (!fs.existsSync(filePath)) return;
|
|
151
176
|
|
|
152
177
|
try {
|
|
153
|
-
const data = yaml.load(
|
|
178
|
+
const data = yaml.load(readVerifiedYAML(filePath), { schema: yaml.JSON_SCHEMA });
|
|
154
179
|
|
|
155
180
|
if (data && data.hashes) {
|
|
156
181
|
for (const h of data.hashes) {
|
|
@@ -220,4 +245,20 @@ function getIOCStats() {
|
|
|
220
245
|
return _cachedIOCStats;
|
|
221
246
|
}
|
|
222
247
|
|
|
223
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Generate .hmac signature files for the 3 YAML IOC files.
|
|
250
|
+
* Call after updating YAML IOCs to sign them for integrity verification.
|
|
251
|
+
*/
|
|
252
|
+
function signYAMLIOCs() {
|
|
253
|
+
const { generateIOCHMAC } = require('./updater.js');
|
|
254
|
+
const yamlFiles = ['packages.yaml', 'builtin.yaml', 'hashes.yaml'];
|
|
255
|
+
for (const file of yamlFiles) {
|
|
256
|
+
const filePath = path.join(IOCS_DIR, file);
|
|
257
|
+
if (!fs.existsSync(filePath)) continue;
|
|
258
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
259
|
+
const hmac = generateIOCHMAC(content);
|
|
260
|
+
fs.writeFileSync(filePath + '.hmac', hmac);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { loadYAMLIOCs, getIOCStats, readVerifiedYAML, signYAMLIOCs };
|
|
@@ -428,6 +428,26 @@ const PLAYBOOKS = {
|
|
|
428
428
|
'Persistence IDE detectee. Le code ecrit dans tasks.json ou la configuration VS Code avec execution automatique ' +
|
|
429
429
|
'a l\'ouverture du dossier (runOn: folderOpen, reveal: silent). Pattern FAMOUS CHOLLIMA / StegaBin. ' +
|
|
430
430
|
'Verifier ~/.config/Code/User/tasks.json et supprimer les taches inconnues.',
|
|
431
|
+
|
|
432
|
+
vm_code_execution:
|
|
433
|
+
'CRITIQUE: Execution de code via le module vm de Node.js. Les methodes vm.runInThisContext(), vm.runInNewContext(), ' +
|
|
434
|
+
'vm.compileFunction() et new vm.Script() permettent d\'executer du code dynamique en contournant la detection eval/Function. ' +
|
|
435
|
+
'Analyser le code source execute. Verifier s\'il s\'agit d\'un moteur de templates ou d\'un payload malveillant.',
|
|
436
|
+
|
|
437
|
+
reflect_code_execution:
|
|
438
|
+
'CRITIQUE: Execution de code via l\'API Reflect. Reflect.construct(Function, [...]) et Reflect.apply(eval, ...) ' +
|
|
439
|
+
'contournent la detection directe de eval/new Function(). Technique d\'evasion avancee. ' +
|
|
440
|
+
'Analyser les arguments passes a Reflect. Supprimer le package si non justifie.',
|
|
441
|
+
|
|
442
|
+
process_binding_abuse:
|
|
443
|
+
'CRITIQUE: Acces direct aux bindings V8 internes via process.binding() ou process._linkedBinding(). ' +
|
|
444
|
+
'Permet l\'execution de commandes (spawn_sync) ou l\'acces au systeme de fichiers (fs) sans passer par les modules Node.js standards. ' +
|
|
445
|
+
'Technique d\'evasion avancee contournant toute la couche d\'abstraction. Supprimer immediatement.',
|
|
446
|
+
|
|
447
|
+
worker_thread_exec:
|
|
448
|
+
'new Worker() avec eval:true detecte. Le code s\'execute dans un thread worker separe, contournant la detection AST du thread principal. ' +
|
|
449
|
+
'Verifier le contenu du code passe au Worker. Si dynamique ou obfusque, supprimer le package. ' +
|
|
450
|
+
'Analyser les communications inter-threads (parentPort, workerData) pour identifier le payload.',
|
|
431
451
|
};
|
|
432
452
|
|
|
433
453
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -1197,6 +1197,58 @@ const RULES = {
|
|
|
1197
1197
|
],
|
|
1198
1198
|
mitre: 'T1546'
|
|
1199
1199
|
},
|
|
1200
|
+
|
|
1201
|
+
vm_code_execution: {
|
|
1202
|
+
id: 'MUADDIB-AST-036',
|
|
1203
|
+
name: 'VM Module Code Execution',
|
|
1204
|
+
severity: 'HIGH',
|
|
1205
|
+
confidence: 'high',
|
|
1206
|
+
description: 'Execution de code dynamique via le module vm de Node.js (vm.runInThisContext, vm.runInNewContext, vm.compileFunction, new vm.Script). Contourne la detection eval/Function.',
|
|
1207
|
+
references: [
|
|
1208
|
+
'https://nodejs.org/api/vm.html',
|
|
1209
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1210
|
+
],
|
|
1211
|
+
mitre: 'T1059'
|
|
1212
|
+
},
|
|
1213
|
+
|
|
1214
|
+
reflect_code_execution: {
|
|
1215
|
+
id: 'MUADDIB-AST-037',
|
|
1216
|
+
name: 'Reflect API Code Execution',
|
|
1217
|
+
severity: 'CRITICAL',
|
|
1218
|
+
confidence: 'high',
|
|
1219
|
+
description: 'Execution de code dynamique via Reflect.construct(Function, [...]) ou Reflect.apply(eval, ...). Contourne la detection directe de eval/Function/new Function.',
|
|
1220
|
+
references: [
|
|
1221
|
+
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect',
|
|
1222
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1223
|
+
],
|
|
1224
|
+
mitre: 'T1059'
|
|
1225
|
+
},
|
|
1226
|
+
|
|
1227
|
+
process_binding_abuse: {
|
|
1228
|
+
id: 'MUADDIB-AST-038',
|
|
1229
|
+
name: 'Process Binding Abuse',
|
|
1230
|
+
severity: 'CRITICAL',
|
|
1231
|
+
confidence: 'high',
|
|
1232
|
+
description: 'Acces direct aux bindings V8 internes via process.binding() ou process._linkedBinding(). Contourne les modules child_process/fs pour execution de commandes ou acces fichiers sans detection.',
|
|
1233
|
+
references: [
|
|
1234
|
+
'https://nodejs.org/api/process.html#processbindingname',
|
|
1235
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1236
|
+
],
|
|
1237
|
+
mitre: 'T1059'
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
worker_thread_exec: {
|
|
1241
|
+
id: 'MUADDIB-AST-039',
|
|
1242
|
+
name: 'Worker Thread Code Execution',
|
|
1243
|
+
severity: 'HIGH',
|
|
1244
|
+
confidence: 'high',
|
|
1245
|
+
description: 'new Worker() avec eval:true execute du code arbitraire dans un thread worker, contournant la detection du thread principal. Technique d\'evasion pour executer du code dynamique hors du scope AST principal.',
|
|
1246
|
+
references: [
|
|
1247
|
+
'https://nodejs.org/api/worker_threads.html',
|
|
1248
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1249
|
+
],
|
|
1250
|
+
mitre: 'T1059'
|
|
1251
|
+
},
|
|
1200
1252
|
};
|
|
1201
1253
|
|
|
1202
1254
|
function getRule(type) {
|
package/src/sarif.js
CHANGED
|
@@ -10,6 +10,11 @@ const pkgVersion = (() => {
|
|
|
10
10
|
}
|
|
11
11
|
})();
|
|
12
12
|
|
|
13
|
+
function sarifUri(filePath) {
|
|
14
|
+
if (!filePath) return '';
|
|
15
|
+
return filePath.split(/[/\\]/).map(encodeURIComponent).join('/');
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
function generateSARIF(results) {
|
|
14
19
|
const sarif = {
|
|
15
20
|
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
@@ -50,7 +55,7 @@ function generateSARIF(results) {
|
|
|
50
55
|
{
|
|
51
56
|
physicalLocation: {
|
|
52
57
|
artifactLocation: {
|
|
53
|
-
uri:
|
|
58
|
+
uri: sarifUri(threat.file),
|
|
54
59
|
uriBaseId: '%SRCROOT%'
|
|
55
60
|
},
|
|
56
61
|
region: {
|
|
@@ -206,8 +206,12 @@ function resolveStringConcat(node) {
|
|
|
206
206
|
*/
|
|
207
207
|
function extractStringValueDeep(node) {
|
|
208
208
|
const concat = resolveStringConcat(node);
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
let result = concat !== null ? concat : extractStringValue(node);
|
|
210
|
+
// Batch 2: strip node: prefix so require('node:child_process') normalizes to 'child_process'
|
|
211
|
+
if (typeof result === 'string' && result.startsWith('node:')) {
|
|
212
|
+
result = result.slice(5);
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
211
215
|
}
|
|
212
216
|
|
|
213
217
|
/**
|
|
@@ -369,6 +373,38 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
369
373
|
}
|
|
370
374
|
}
|
|
371
375
|
}
|
|
376
|
+
|
|
377
|
+
// Batch 2: Detect destructuring of process.env: const { TOKEN, SECRET } = process.env
|
|
378
|
+
if (node.id?.type === 'ObjectPattern' &&
|
|
379
|
+
node.init?.type === 'MemberExpression' &&
|
|
380
|
+
node.init.object?.type === 'Identifier' && node.init.object.name === 'process' &&
|
|
381
|
+
node.init.property?.type === 'Identifier' && node.init.property.name === 'env') {
|
|
382
|
+
for (const prop of node.id.properties) {
|
|
383
|
+
if (prop.type === 'Property' && prop.key?.type === 'Identifier') {
|
|
384
|
+
const envVar = prop.key.name;
|
|
385
|
+
if (SAFE_ENV_VARS.includes(envVar)) continue;
|
|
386
|
+
const envLower = envVar.toLowerCase();
|
|
387
|
+
if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) continue;
|
|
388
|
+
if (ENV_SENSITIVE_KEYWORDS.some(s => envVar.toUpperCase().includes(s))) {
|
|
389
|
+
ctx.threats.push({
|
|
390
|
+
type: 'env_access',
|
|
391
|
+
severity: 'HIGH',
|
|
392
|
+
message: `Destructured access to sensitive env var: const { ${envVar} } = process.env.`,
|
|
393
|
+
file: ctx.relFile
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// RestElement: const { ...all } = process.env → env harvesting
|
|
398
|
+
if (prop.type === 'RestElement') {
|
|
399
|
+
ctx.threats.push({
|
|
400
|
+
type: 'env_harvesting_dynamic',
|
|
401
|
+
severity: 'HIGH',
|
|
402
|
+
message: 'Environment variable harvesting via rest destructuring: const { ...rest } = process.env.',
|
|
403
|
+
file: ctx.relFile
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
372
408
|
}
|
|
373
409
|
|
|
374
410
|
function handleCallExpression(node, ctx) {
|
|
@@ -1035,6 +1071,29 @@ function handleCallExpression(node, ctx) {
|
|
|
1035
1071
|
}
|
|
1036
1072
|
}
|
|
1037
1073
|
|
|
1074
|
+
// Batch 2: Detect indirect eval/Function via logical expression: (false || eval)(code), (0 || Function)(code)
|
|
1075
|
+
if (node.callee.type === 'LogicalExpression' && node.callee.operator === '||') {
|
|
1076
|
+
const right = node.callee.right;
|
|
1077
|
+
if (right?.type === 'Identifier') {
|
|
1078
|
+
if (right.name === 'eval') {
|
|
1079
|
+
ctx.hasEvalInFile = true;
|
|
1080
|
+
ctx.threats.push({
|
|
1081
|
+
type: 'dangerous_call_eval',
|
|
1082
|
+
severity: 'HIGH',
|
|
1083
|
+
message: 'Indirect eval via logical expression ((false || eval)) — evasion technique.',
|
|
1084
|
+
file: ctx.relFile
|
|
1085
|
+
});
|
|
1086
|
+
} else if (right.name === 'Function') {
|
|
1087
|
+
ctx.threats.push({
|
|
1088
|
+
type: 'dangerous_call_function',
|
|
1089
|
+
severity: 'MEDIUM',
|
|
1090
|
+
message: 'Indirect Function via logical expression ((false || Function)) — evasion technique.',
|
|
1091
|
+
file: ctx.relFile
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1038
1097
|
// Detect crypto.createDecipher/createDecipheriv and module._compile
|
|
1039
1098
|
if (node.callee.type === 'MemberExpression') {
|
|
1040
1099
|
const prop = node.callee.property;
|
|
@@ -1049,24 +1108,41 @@ function handleCallExpression(node, ctx) {
|
|
|
1049
1108
|
});
|
|
1050
1109
|
}
|
|
1051
1110
|
if (propName === '_compile') {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1111
|
+
// Context-aware gating: only flag _compile when the Module API is plausibly in scope.
|
|
1112
|
+
// Custom class methods (e.g. blessed's Tput.prototype._compile) are not malware.
|
|
1113
|
+
const calleeObj = node.callee.object;
|
|
1114
|
+
const isThisCall = calleeObj.type === 'ThisExpression';
|
|
1115
|
+
const isModuleIdentifier = calleeObj.type === 'Identifier' &&
|
|
1116
|
+
(calleeObj.name === 'module' || calleeObj.name === 'Module' || calleeObj.name === 'm');
|
|
1117
|
+
const isConstructed = calleeObj.type === 'NewExpression' || calleeObj.type === 'CallExpression';
|
|
1118
|
+
const isMemberChain = calleeObj.type === 'MemberExpression';
|
|
1119
|
+
// Skip: this._compile() is always a custom instance method, not Node Module API
|
|
1120
|
+
// Detect: module/Module/m identifier, new X()._compile(), X()._compile(), X.Y._compile()
|
|
1121
|
+
// Other identifiers: only if require('module') or module.constructor is in file
|
|
1122
|
+
const shouldDetect = !isThisCall && (
|
|
1123
|
+
isModuleIdentifier || isConstructed || isMemberChain ||
|
|
1124
|
+
(calleeObj.type === 'Identifier' && ctx.hasModuleImport)
|
|
1125
|
+
);
|
|
1126
|
+
if (shouldDetect) {
|
|
1127
|
+
ctx.hasDynamicExec = true;
|
|
1061
1128
|
ctx.threats.push({
|
|
1062
|
-
type: '
|
|
1129
|
+
type: 'module_compile',
|
|
1063
1130
|
severity: 'CRITICAL',
|
|
1064
|
-
message: '
|
|
1131
|
+
message: 'module._compile() detected — executes arbitrary code from string in module context (flatmap-stream pattern).',
|
|
1065
1132
|
file: ctx.relFile
|
|
1066
1133
|
});
|
|
1134
|
+
// SANDWORM_MODE: Module._compile with non-literal argument = dynamic code execution
|
|
1135
|
+
if (node.arguments.length >= 1 && !hasOnlyStringLiteralArgs(node)) {
|
|
1136
|
+
ctx.threats.push({
|
|
1137
|
+
type: 'module_compile_dynamic',
|
|
1138
|
+
severity: 'CRITICAL',
|
|
1139
|
+
message: 'In-memory code execution via Module._compile(). Common malware evasion technique.',
|
|
1140
|
+
file: ctx.relFile
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
// Module._compile counts as temp file exec for write-execute-delete pattern
|
|
1144
|
+
ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasDevShmInContent;
|
|
1067
1145
|
}
|
|
1068
|
-
// Module._compile counts as temp file exec for write-execute-delete pattern
|
|
1069
|
-
ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasDevShmInContent;
|
|
1070
1146
|
}
|
|
1071
1147
|
|
|
1072
1148
|
// SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
|
|
@@ -1118,6 +1194,79 @@ function handleCallExpression(node, ctx) {
|
|
|
1118
1194
|
}
|
|
1119
1195
|
}
|
|
1120
1196
|
|
|
1197
|
+
// Batch 1: vm.* code execution — vm.runInThisContext, vm.runInNewContext, vm.compileFunction, vm.Script
|
|
1198
|
+
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
1199
|
+
const vmMethod = node.callee.property.name;
|
|
1200
|
+
if (['runInThisContext', 'runInNewContext', 'compileFunction'].includes(vmMethod)) {
|
|
1201
|
+
// NOTE: Do NOT set ctx.hasDynamicExec — vm.* is legitimately used by bundlers
|
|
1202
|
+
// (webpack, jest, etc.) and must not trigger compound detections (zlib_inflate_eval,
|
|
1203
|
+
// fetch_decrypt_exec) which were designed for eval/Function patterns.
|
|
1204
|
+
ctx.threats.push({
|
|
1205
|
+
type: 'vm_code_execution',
|
|
1206
|
+
severity: 'HIGH',
|
|
1207
|
+
message: `vm.${vmMethod}() — dynamic code execution via Node.js vm module bypasses eval detection.`,
|
|
1208
|
+
file: ctx.relFile
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Batch 1: Reflect.construct(Function, [...]) / Reflect.apply(eval, null, [...])
|
|
1214
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
1215
|
+
node.callee.object?.type === 'Identifier' && node.callee.object.name === 'Reflect' &&
|
|
1216
|
+
node.callee.property?.type === 'Identifier') {
|
|
1217
|
+
const reflectMethod = node.callee.property.name;
|
|
1218
|
+
if (reflectMethod === 'construct' && node.arguments.length >= 2) {
|
|
1219
|
+
const target = node.arguments[0];
|
|
1220
|
+
if (target.type === 'Identifier' && target.name === 'Function') {
|
|
1221
|
+
ctx.hasDynamicExec = true;
|
|
1222
|
+
ctx.threats.push({
|
|
1223
|
+
type: 'reflect_code_execution',
|
|
1224
|
+
severity: 'CRITICAL',
|
|
1225
|
+
message: 'Reflect.construct(Function, [...]) — indirect Function construction bypasses new Function() detection.',
|
|
1226
|
+
file: ctx.relFile
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
} else if (reflectMethod === 'apply' && node.arguments.length >= 3) {
|
|
1230
|
+
const target = node.arguments[0];
|
|
1231
|
+
if (target.type === 'Identifier' && (target.name === 'eval' || target.name === 'Function')) {
|
|
1232
|
+
ctx.hasDynamicExec = true;
|
|
1233
|
+
ctx.threats.push({
|
|
1234
|
+
type: 'reflect_code_execution',
|
|
1235
|
+
severity: 'CRITICAL',
|
|
1236
|
+
message: `Reflect.apply(${target.name}, ...) — indirect ${target.name} invocation bypasses direct call detection.`,
|
|
1237
|
+
file: ctx.relFile
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Batch 1: process.binding('spawn_sync'/'fs') / process._linkedBinding(...)
|
|
1244
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
1245
|
+
node.callee.object?.type === 'Identifier' && node.callee.object.name === 'process' &&
|
|
1246
|
+
node.callee.property?.type === 'Identifier' &&
|
|
1247
|
+
(node.callee.property.name === 'binding' || node.callee.property.name === '_linkedBinding') &&
|
|
1248
|
+
node.arguments.length >= 1) {
|
|
1249
|
+
const bindArg = node.arguments[0];
|
|
1250
|
+
const bindStr = bindArg?.type === 'Literal' && typeof bindArg.value === 'string' ? bindArg.value : null;
|
|
1251
|
+
const dangerousBindings = ['spawn_sync', 'fs', 'pipe_wrap', 'tcp_wrap', 'tls_wrap', 'udp_wrap', 'process_wrap'];
|
|
1252
|
+
if (bindStr && dangerousBindings.includes(bindStr)) {
|
|
1253
|
+
ctx.threats.push({
|
|
1254
|
+
type: 'process_binding_abuse',
|
|
1255
|
+
severity: 'CRITICAL',
|
|
1256
|
+
message: `process.${node.callee.property.name}('${bindStr}') — direct V8 binding access bypasses child_process/fs module detection.`,
|
|
1257
|
+
file: ctx.relFile
|
|
1258
|
+
});
|
|
1259
|
+
} else if (!bindStr) {
|
|
1260
|
+
// Dynamic binding argument — suspicious
|
|
1261
|
+
ctx.threats.push({
|
|
1262
|
+
type: 'process_binding_abuse',
|
|
1263
|
+
severity: 'HIGH',
|
|
1264
|
+
message: `process.${node.callee.property.name}() with dynamic argument — potential V8 binding abuse.`,
|
|
1265
|
+
file: ctx.relFile
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1121
1270
|
// SANDWORM_MODE R8: dns.resolve detection moved to walk.ancestor() in ast.js (FIX 5)
|
|
1122
1271
|
}
|
|
1123
1272
|
|
|
@@ -1125,8 +1274,10 @@ function handleImportExpression(node, ctx) {
|
|
|
1125
1274
|
if (node.source) {
|
|
1126
1275
|
const src = node.source;
|
|
1127
1276
|
if (src.type === 'Literal' && typeof src.value === 'string') {
|
|
1128
|
-
const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns', 'tls'];
|
|
1129
|
-
|
|
1277
|
+
const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns', 'tls', 'worker_threads'];
|
|
1278
|
+
// Batch 2: strip node: prefix so import('node:child_process') normalizes
|
|
1279
|
+
const modName = src.value.startsWith('node:') ? src.value.slice(5) : src.value;
|
|
1280
|
+
if (dangerousModules.includes(modName)) {
|
|
1130
1281
|
ctx.threats.push({
|
|
1131
1282
|
type: 'dynamic_import',
|
|
1132
1283
|
severity: 'HIGH',
|
|
@@ -1159,6 +1310,19 @@ function handleNewExpression(node, ctx) {
|
|
|
1159
1310
|
}
|
|
1160
1311
|
}
|
|
1161
1312
|
|
|
1313
|
+
// Batch 1: new vm.Script(code) — dynamic code compilation via vm module
|
|
1314
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
1315
|
+
node.callee.property?.type === 'Identifier' && node.callee.property.name === 'Script' &&
|
|
1316
|
+
node.arguments.length >= 1 && !hasOnlyStringLiteralArgs(node)) {
|
|
1317
|
+
// NOTE: Do NOT set ctx.hasDynamicExec — same rationale as vm.runInThisContext above.
|
|
1318
|
+
ctx.threats.push({
|
|
1319
|
+
type: 'vm_code_execution',
|
|
1320
|
+
severity: 'HIGH',
|
|
1321
|
+
message: 'new vm.Script() with dynamic code — vm module code compilation bypasses eval detection.',
|
|
1322
|
+
file: ctx.relFile
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1162
1326
|
// Detect new Proxy(process.env, handler)
|
|
1163
1327
|
if (node.callee.type === 'Identifier' && node.callee.name === 'Proxy' && node.arguments.length >= 2) {
|
|
1164
1328
|
const target = node.arguments[0];
|
|
@@ -1182,6 +1346,25 @@ function handleNewExpression(node, ctx) {
|
|
|
1182
1346
|
});
|
|
1183
1347
|
}
|
|
1184
1348
|
}
|
|
1349
|
+
|
|
1350
|
+
// Batch 2: new Worker(code, { eval: true }) — worker_threads code execution
|
|
1351
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'Worker' &&
|
|
1352
|
+
node.arguments.length >= 2) {
|
|
1353
|
+
const opts = node.arguments[1];
|
|
1354
|
+
if (opts?.type === 'ObjectExpression') {
|
|
1355
|
+
const evalProp = opts.properties?.find(p =>
|
|
1356
|
+
p.key?.name === 'eval' && p.value?.value === true);
|
|
1357
|
+
if (evalProp) {
|
|
1358
|
+
ctx.hasDynamicExec = true;
|
|
1359
|
+
ctx.threats.push({
|
|
1360
|
+
type: 'worker_thread_exec',
|
|
1361
|
+
severity: 'HIGH',
|
|
1362
|
+
message: 'new Worker() with eval:true — executes arbitrary code in worker thread, bypasses main thread detection.',
|
|
1363
|
+
file: ctx.relFile
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1185
1368
|
}
|
|
1186
1369
|
|
|
1187
1370
|
function handleLiteral(node, ctx) {
|
|
@@ -1259,7 +1442,9 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
1259
1442
|
// Assigning require('child_process') or its methods to an object property
|
|
1260
1443
|
if (node.right.type === 'CallExpression' && getCallName(node.right) === 'require' &&
|
|
1261
1444
|
node.right.arguments.length > 0 && node.right.arguments[0]?.type === 'Literal') {
|
|
1262
|
-
const
|
|
1445
|
+
const rawMod = node.right.arguments[0].value;
|
|
1446
|
+
// Batch 2: strip node: prefix
|
|
1447
|
+
const mod = typeof rawMod === 'string' && rawMod.startsWith('node:') ? rawMod.slice(5) : rawMod;
|
|
1263
1448
|
if (mod === 'child_process' || mod === 'fs' || mod === 'net' || mod === 'dns') {
|
|
1264
1449
|
ctx.threats.push({
|
|
1265
1450
|
type: 'dynamic_require',
|
|
@@ -1272,16 +1457,20 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
1272
1457
|
// Assigning require('child_process').exec to an object property
|
|
1273
1458
|
if (node.right.type === 'MemberExpression' && node.right.object?.type === 'CallExpression' &&
|
|
1274
1459
|
getCallName(node.right.object) === 'require' &&
|
|
1275
|
-
node.right.object.arguments.length > 0 && node.right.object.arguments[0]?.type === 'Literal'
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1460
|
+
node.right.object.arguments.length > 0 && node.right.object.arguments[0]?.type === 'Literal') {
|
|
1461
|
+
const reqModRaw = node.right.object.arguments[0].value;
|
|
1462
|
+
// Batch 2: strip node: prefix
|
|
1463
|
+
const reqMod = typeof reqModRaw === 'string' && reqModRaw.startsWith('node:') ? reqModRaw.slice(5) : reqModRaw;
|
|
1464
|
+
if (reqMod === 'child_process') {
|
|
1465
|
+
const method = node.right.property?.type === 'Identifier' ? node.right.property.name : null;
|
|
1466
|
+
if (method && ['exec', 'execSync', 'spawn', 'execFile'].includes(method)) {
|
|
1467
|
+
ctx.threats.push({
|
|
1468
|
+
type: 'dangerous_exec',
|
|
1469
|
+
severity: 'HIGH',
|
|
1470
|
+
message: `Object property indirection: ${propName} = require('child_process').${method} — hiding exec in object property.`,
|
|
1471
|
+
file: ctx.relFile
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1285
1474
|
}
|
|
1286
1475
|
}
|
|
1287
1476
|
// Assigning eval or Function to an object property
|
|
@@ -1560,8 +1749,10 @@ function handleWithStatement(node, ctx) {
|
|
|
1560
1749
|
// When used with require(), it allows calling exec(), spawn() etc. without explicit reference.
|
|
1561
1750
|
if (node.object?.type === 'CallExpression' && getCallName(node.object) === 'require') {
|
|
1562
1751
|
const arg = node.object.arguments[0];
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1752
|
+
const rawModName = arg?.type === 'Literal' ? arg.value : null;
|
|
1753
|
+
// Batch 2: strip node: prefix
|
|
1754
|
+
const modName = typeof rawModName === 'string' && rawModName.startsWith('node:') ? rawModName.slice(5) : rawModName;
|
|
1755
|
+
const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns', 'worker_threads'];
|
|
1565
1756
|
if (modName && dangerousModules.includes(modName)) {
|
|
1566
1757
|
ctx.hasDynamicExec = true;
|
|
1567
1758
|
ctx.threats.push({
|
package/src/scanner/ast.js
CHANGED
|
@@ -116,6 +116,8 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
116
116
|
// Content-level MCP detection: MCP keyword + writeFileSync + MCP config path in same file
|
|
117
117
|
// Path co-occurrence prevents FPs where a file reads MCP config but writes elsewhere.
|
|
118
118
|
// Read-only pattern (readFileSync without writeFileSync to MCP) is not injection.
|
|
119
|
+
// Module API context: require('module') or module.constructor usage
|
|
120
|
+
hasModuleImport: /require\s*\(\s*['"]module['"]\s*\)/.test(content) || /module\.constructor/.test(content),
|
|
119
121
|
hasMcpContentKeywords: (/\bmcpServers\b/.test(content) || /\bmcp\.json\b/.test(content) || /\bclaude_desktop_config\b/.test(content)) &&
|
|
120
122
|
/\bwriteFileSync\b|\bwriteFile\s*\(/.test(content) &&
|
|
121
123
|
(/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content))
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -17,6 +17,9 @@ const MODULE_SOURCE_METHODS = {
|
|
|
17
17
|
readFileSync: 'credential_read', readFile: 'credential_read',
|
|
18
18
|
readdirSync: 'credential_read', readdir: 'credential_read'
|
|
19
19
|
},
|
|
20
|
+
'fs/promises': {
|
|
21
|
+
readFile: 'credential_read', readdir: 'credential_read'
|
|
22
|
+
},
|
|
20
23
|
child_process: {
|
|
21
24
|
exec: 'command_output', execSync: 'command_output',
|
|
22
25
|
spawn: 'command_output', spawnSync: 'command_output'
|
|
@@ -53,12 +56,14 @@ function buildTaintMap(ast) {
|
|
|
53
56
|
walk.simple(ast, {
|
|
54
57
|
VariableDeclarator(node) {
|
|
55
58
|
if (!node.init) return;
|
|
59
|
+
let init = node.init;
|
|
60
|
+
if (init.type === 'AwaitExpression') init = init.argument;
|
|
56
61
|
|
|
57
62
|
// Pattern: const x = require("os")
|
|
58
|
-
if (node.id.type === 'Identifier' &&
|
|
59
|
-
const callee =
|
|
60
|
-
if (callee.type === 'Identifier' && callee.name === 'require' &&
|
|
61
|
-
const arg =
|
|
63
|
+
if (node.id.type === 'Identifier' && init.type === 'CallExpression') {
|
|
64
|
+
const callee = init.callee;
|
|
65
|
+
if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments.length > 0) {
|
|
66
|
+
const arg = init.arguments[0];
|
|
62
67
|
if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
|
|
63
68
|
taintMap.set(node.id.name, { source: arg.value, detail: arg.value });
|
|
64
69
|
}
|
|
@@ -66,10 +71,10 @@ function buildTaintMap(ast) {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
// Pattern: const { exec, spawn } = require("child_process")
|
|
69
|
-
if (node.id.type === 'ObjectPattern' &&
|
|
70
|
-
const callee =
|
|
71
|
-
if (callee.type === 'Identifier' && callee.name === 'require' &&
|
|
72
|
-
const arg =
|
|
74
|
+
if (node.id.type === 'ObjectPattern' && init.type === 'CallExpression') {
|
|
75
|
+
const callee = init.callee;
|
|
76
|
+
if (callee.type === 'Identifier' && callee.name === 'require' && init.arguments.length > 0) {
|
|
77
|
+
const arg = init.arguments[0];
|
|
73
78
|
if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
|
|
74
79
|
for (const prop of node.id.properties) {
|
|
75
80
|
if (prop.type === 'Property' && prop.value?.type === 'Identifier') {
|
|
@@ -82,9 +87,9 @@ function buildTaintMap(ast) {
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
// Pattern: const e = process.env
|
|
85
|
-
if (node.id.type === 'Identifier' &&
|
|
86
|
-
const obj =
|
|
87
|
-
const prop =
|
|
90
|
+
if (node.id.type === 'Identifier' && init.type === 'MemberExpression') {
|
|
91
|
+
const obj = init.object;
|
|
92
|
+
const prop = init.property;
|
|
88
93
|
if (obj?.type === 'Identifier' && obj.name === 'process' &&
|
|
89
94
|
prop?.type === 'Identifier' && prop.name === 'env') {
|
|
90
95
|
taintMap.set(node.id.name, { source: 'process.env', detail: 'process.env' });
|
|
@@ -92,9 +97,9 @@ function buildTaintMap(ast) {
|
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
// Pattern: const h = x.homedir where x is tainted as "os"
|
|
95
|
-
if (node.id.type === 'Identifier' &&
|
|
96
|
-
const obj =
|
|
97
|
-
const prop =
|
|
100
|
+
if (node.id.type === 'Identifier' && init.type === 'MemberExpression') {
|
|
101
|
+
const obj = init.object;
|
|
102
|
+
const prop = init.property;
|
|
98
103
|
if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
|
|
99
104
|
const parentTaint = taintMap.get(obj.name);
|
|
100
105
|
if (parentTaint && TRACKED_MODULES.has(parentTaint.source)) {
|
|
@@ -162,16 +167,19 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
162
167
|
|
|
163
168
|
VariableDeclarator(node) {
|
|
164
169
|
if (node.id?.type === 'Identifier' && node.init) {
|
|
165
|
-
|
|
170
|
+
let initNode = node.init;
|
|
171
|
+
if (initNode.type === 'AwaitExpression') initNode = initNode.argument;
|
|
172
|
+
|
|
173
|
+
if (containsSensitiveLiteral(initNode)) {
|
|
166
174
|
sensitivePathVars.add(node.id.name);
|
|
167
175
|
}
|
|
168
176
|
// Propagate sensitive vars through path.join/resolve
|
|
169
|
-
if (
|
|
170
|
-
const obj =
|
|
171
|
-
const prop =
|
|
177
|
+
if (initNode.type === 'CallExpression' && initNode.callee?.type === 'MemberExpression') {
|
|
178
|
+
const obj = initNode.callee.object;
|
|
179
|
+
const prop = initNode.callee.property;
|
|
172
180
|
if (obj?.type === 'Identifier' && obj.name === 'path' &&
|
|
173
181
|
prop?.type === 'Identifier' && (prop.name === 'join' || prop.name === 'resolve')) {
|
|
174
|
-
if (
|
|
182
|
+
if (initNode.arguments.some(a =>
|
|
175
183
|
(a.type === 'Identifier' && sensitivePathVars.has(a.name)) ||
|
|
176
184
|
(a.type === 'MemberExpression' && a.object?.type === 'Identifier' && sensitivePathVars.has(a.object.name))
|
|
177
185
|
)) {
|
|
@@ -179,10 +187,26 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
179
187
|
}
|
|
180
188
|
}
|
|
181
189
|
}
|
|
190
|
+
// Propagate taint through spread: const payload = { ...creds }
|
|
191
|
+
if (initNode.type === 'ObjectExpression') {
|
|
192
|
+
for (const prop of initNode.properties) {
|
|
193
|
+
if (prop.type === 'SpreadElement' && prop.argument?.type === 'Identifier') {
|
|
194
|
+
if (sensitivePathVars.has(prop.argument.name)) {
|
|
195
|
+
sensitivePathVars.add(node.id.name);
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
const taint = taintMap.get(prop.argument.name);
|
|
199
|
+
if (taint && (taint.source === 'process.env' || MODULE_SOURCE_METHODS[taint.source])) {
|
|
200
|
+
sensitivePathVars.add(node.id.name);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
182
206
|
// Track exec result capture: const output = execSync('cmd')
|
|
183
|
-
if (
|
|
207
|
+
if (initNode.type === 'CallExpression') {
|
|
184
208
|
let execName = null;
|
|
185
|
-
const initCallee =
|
|
209
|
+
const initCallee = initNode.callee;
|
|
186
210
|
if (initCallee?.type === 'Identifier' && EXEC_METHODS.has(initCallee.name)) {
|
|
187
211
|
const taint = taintMap.get(initCallee.name);
|
|
188
212
|
if (taint && taint.source === 'child_process') {
|
|
@@ -198,7 +222,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
198
222
|
}
|
|
199
223
|
}
|
|
200
224
|
if (execName) {
|
|
201
|
-
execResultNodes.add(
|
|
225
|
+
execResultNodes.add(initNode);
|
|
202
226
|
sources.push({
|
|
203
227
|
type: 'command_output',
|
|
204
228
|
name: execName,
|
|
@@ -225,6 +249,24 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
225
249
|
}
|
|
226
250
|
}
|
|
227
251
|
|
|
252
|
+
// fs.promises.readFile(path) — 3-level member chain
|
|
253
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
254
|
+
node.callee.object?.type === 'MemberExpression') {
|
|
255
|
+
const outerObj = node.callee.object.object;
|
|
256
|
+
const mid = node.callee.object.property;
|
|
257
|
+
const method = node.callee.property;
|
|
258
|
+
if (outerObj?.type === 'Identifier' && mid?.type === 'Identifier' && mid.name === 'promises' &&
|
|
259
|
+
method?.type === 'Identifier' && (method.name === 'readFile' || method.name === 'readdir')) {
|
|
260
|
+
const isFs = outerObj.name === 'fs' || (taintMap.get(outerObj.name)?.source === 'fs');
|
|
261
|
+
if (isFs) {
|
|
262
|
+
const arg = node.arguments[0];
|
|
263
|
+
if (arg && isCredentialPath(arg, sensitivePathVars)) {
|
|
264
|
+
sources.push({ type: 'credential_read', name: `fs.promises.${method.name}`, line: node.loc?.start?.line });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
228
270
|
if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
|
|
229
271
|
sinks.push({
|
|
230
272
|
type: 'network_send',
|
|
@@ -157,8 +157,22 @@ function analyzeExports(filePath) {
|
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
// First pass: collect require assignments and tainted variable assignments
|
|
160
|
+
// First pass: collect require assignments, ES imports, and tainted variable assignments
|
|
161
161
|
walkAST(ast, (node) => {
|
|
162
|
+
// import fs from 'fs' / import { readFileSync } from 'fs'
|
|
163
|
+
if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
|
|
164
|
+
const modName = node.source.value;
|
|
165
|
+
if (SENSITIVE_MODULES.has(modName)) {
|
|
166
|
+
for (const spec of node.specifiers) {
|
|
167
|
+
if (spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier') {
|
|
168
|
+
moduleVars[spec.local.name] = modName;
|
|
169
|
+
} else if (spec.type === 'ImportSpecifier') {
|
|
170
|
+
moduleVars[spec.local.name] = modName;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
162
176
|
// const fs = require('fs')
|
|
163
177
|
if (node.type === 'VariableDeclaration') {
|
|
164
178
|
for (const decl of node.declarations) {
|
|
@@ -197,6 +211,64 @@ function analyzeExports(filePath) {
|
|
|
197
211
|
// Second pass: find exports and check if they are tainted
|
|
198
212
|
const exports = {};
|
|
199
213
|
walkAST(ast, (node) => {
|
|
214
|
+
// export function foo() {...} / export const foo = expr
|
|
215
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
|
|
216
|
+
const decl = node.declaration;
|
|
217
|
+
if (decl.type === 'FunctionDeclaration' && decl.id) {
|
|
218
|
+
const funcBody = decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : null;
|
|
219
|
+
if (funcBody) {
|
|
220
|
+
const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
|
|
221
|
+
if (bodyTaint) {
|
|
222
|
+
exports[decl.id.name] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (decl.type === 'VariableDeclaration') {
|
|
227
|
+
for (const vDecl of decl.declarations) {
|
|
228
|
+
if (!vDecl.id || vDecl.id.type !== 'Identifier') continue;
|
|
229
|
+
if (vDecl.init) {
|
|
230
|
+
const taint = checkNodeTaint(vDecl.init, moduleVars);
|
|
231
|
+
if (taint) {
|
|
232
|
+
exports[vDecl.id.name] = { tainted: true, source: taint.source, detail: taint.detail };
|
|
233
|
+
} else if (vDecl.init.type === 'Identifier' && taintedVars[vDecl.init.name]) {
|
|
234
|
+
const t = taintedVars[vDecl.init.name];
|
|
235
|
+
exports[vDecl.id.name] = { tainted: true, source: t.source, detail: t.detail };
|
|
236
|
+
} else {
|
|
237
|
+
const funcBody = getFunctionBody(vDecl.init);
|
|
238
|
+
if (funcBody) {
|
|
239
|
+
const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
|
|
240
|
+
if (bodyTaint) {
|
|
241
|
+
exports[vDecl.id.name] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// export default function() {...} / export default expr
|
|
251
|
+
if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
|
|
252
|
+
const decl = node.declaration;
|
|
253
|
+
const taint = checkNodeTaint(decl, moduleVars);
|
|
254
|
+
if (taint) {
|
|
255
|
+
exports['default'] = { tainted: true, source: taint.source, detail: taint.detail };
|
|
256
|
+
} else if (decl.type === 'Identifier' && taintedVars[decl.name]) {
|
|
257
|
+
const t = taintedVars[decl.name];
|
|
258
|
+
exports['default'] = { tainted: true, source: t.source, detail: t.detail };
|
|
259
|
+
} else {
|
|
260
|
+
const funcBody = (decl.type === 'FunctionDeclaration' || decl.type === 'FunctionExpression' || decl.type === 'ArrowFunctionExpression')
|
|
261
|
+
? (decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : (decl.body ? [{ type: 'ReturnStatement', argument: decl.body }] : null))
|
|
262
|
+
: getFunctionBody(decl);
|
|
263
|
+
if (funcBody) {
|
|
264
|
+
const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
|
|
265
|
+
if (bodyTaint) {
|
|
266
|
+
exports['default'] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
200
272
|
// module.exports = value OR module.exports = { ... }
|
|
201
273
|
if (isModuleExportsAssign(node)) {
|
|
202
274
|
const value = node.expression.right;
|
|
@@ -416,6 +488,9 @@ function detectCrossFileFlows(graph, taintedExports, packagePath) {
|
|
|
416
488
|
const localTaint = collectImportTaint(ast, relFile, graph, expandedTaint, packagePath);
|
|
417
489
|
if (Object.keys(localTaint).length === 0) continue;
|
|
418
490
|
|
|
491
|
+
// Propagate taint through local variable assignments (e.g., const data = read())
|
|
492
|
+
propagateLocalTaint(ast, localTaint);
|
|
493
|
+
|
|
419
494
|
// Find sinks that use tainted variables
|
|
420
495
|
const sinks = findSinksUsingTainted(ast, localTaint);
|
|
421
496
|
for (const sink of sinks) {
|
|
@@ -464,6 +539,69 @@ function expandTaintThroughReexports(graph, taintedExports, packagePath) {
|
|
|
464
539
|
if (!expanded[relFile]) expanded[relFile] = {};
|
|
465
540
|
const fileDir = path.dirname(absFile);
|
|
466
541
|
walkAST(ast, (node) => {
|
|
542
|
+
// ES re-export: export { foo } from './reader'
|
|
543
|
+
if (node.type === 'ExportNamedDeclaration' && node.source && typeof node.source.value === 'string') {
|
|
544
|
+
const spec = node.source.value;
|
|
545
|
+
if (isLocalImport(spec)) {
|
|
546
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
547
|
+
if (resolved && expanded[resolved]) {
|
|
548
|
+
for (const specifier of node.specifiers) {
|
|
549
|
+
const importedName = specifier.exported.name || specifier.exported.value;
|
|
550
|
+
const sourceName = specifier.local.name || specifier.local.value;
|
|
551
|
+
const srcTaint = expanded[resolved][sourceName];
|
|
552
|
+
if (srcTaint && srcTaint.tainted && !expanded[relFile][importedName]) {
|
|
553
|
+
expanded[relFile][importedName] = {
|
|
554
|
+
tainted: true,
|
|
555
|
+
source: srcTaint.source,
|
|
556
|
+
detail: srcTaint.detail,
|
|
557
|
+
sourceFile: srcTaint.sourceFile || resolved,
|
|
558
|
+
};
|
|
559
|
+
changed = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ES export of tainted variable: export const x = taintedVar
|
|
568
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
|
|
569
|
+
const decl = node.declaration;
|
|
570
|
+
if (decl.type === 'VariableDeclaration') {
|
|
571
|
+
for (const vDecl of decl.declarations) {
|
|
572
|
+
if (vDecl.id?.type === 'Identifier' && vDecl.init?.type === 'Identifier' && localTaint[vDecl.init.name]) {
|
|
573
|
+
if (!expanded[relFile][vDecl.id.name]) {
|
|
574
|
+
expanded[relFile][vDecl.id.name] = {
|
|
575
|
+
tainted: true,
|
|
576
|
+
source: localTaint[vDecl.init.name].source,
|
|
577
|
+
detail: localTaint[vDecl.init.name].detail,
|
|
578
|
+
sourceFile: localTaint[vDecl.init.name].sourceFile,
|
|
579
|
+
};
|
|
580
|
+
changed = true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ES export default of tainted variable
|
|
589
|
+
if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
|
|
590
|
+
const decl = node.declaration;
|
|
591
|
+
if (decl.type === 'Identifier' && localTaint[decl.name]) {
|
|
592
|
+
if (!expanded[relFile]['default']) {
|
|
593
|
+
expanded[relFile]['default'] = {
|
|
594
|
+
tainted: true,
|
|
595
|
+
source: localTaint[decl.name].source,
|
|
596
|
+
detail: localTaint[decl.name].detail,
|
|
597
|
+
sourceFile: localTaint[decl.name].sourceFile,
|
|
598
|
+
};
|
|
599
|
+
changed = true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
467
605
|
if (!isModuleExportsAssign(node)) return;
|
|
468
606
|
const value = node.expression.right;
|
|
469
607
|
const exportName = getExportName(node.expression.left);
|
|
@@ -582,6 +720,42 @@ function collectImportTaint(ast, currentFile, graph, taintedExports, packagePath
|
|
|
582
720
|
const localTaint = {};
|
|
583
721
|
const fileDir = path.dirname(path.resolve(packagePath, currentFile));
|
|
584
722
|
|
|
723
|
+
// Handle ES import declarations
|
|
724
|
+
walkAST(ast, (node) => {
|
|
725
|
+
if (node.type !== 'ImportDeclaration' || !node.source || typeof node.source.value !== 'string') return;
|
|
726
|
+
const spec = node.source.value;
|
|
727
|
+
if (!isLocalImport(spec)) return;
|
|
728
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
729
|
+
if (!resolved || !taintedExports[resolved]) return;
|
|
730
|
+
const modTaint = taintedExports[resolved];
|
|
731
|
+
|
|
732
|
+
for (const specifier of node.specifiers) {
|
|
733
|
+
if (specifier.type === 'ImportDefaultSpecifier') {
|
|
734
|
+
const defTaint = modTaint['default'];
|
|
735
|
+
if (defTaint && defTaint.tainted) {
|
|
736
|
+
localTaint[specifier.local.name] = {
|
|
737
|
+
source: defTaint.source,
|
|
738
|
+
detail: defTaint.detail || '',
|
|
739
|
+
sourceFile: defTaint.sourceFile || resolved,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
localTaint['__module__' + specifier.local.name] = { resolved, modTaint };
|
|
743
|
+
} else if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
744
|
+
localTaint['__module__' + specifier.local.name] = { resolved, modTaint };
|
|
745
|
+
} else if (specifier.type === 'ImportSpecifier') {
|
|
746
|
+
const importedName = specifier.imported.name || specifier.imported.value;
|
|
747
|
+
if (modTaint[importedName] && modTaint[importedName].tainted) {
|
|
748
|
+
localTaint[specifier.local.name] = {
|
|
749
|
+
source: modTaint[importedName].source,
|
|
750
|
+
detail: modTaint[importedName].detail || '',
|
|
751
|
+
sourceFile: modTaint[importedName].sourceFile || resolved,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Handle CommonJS require() imports
|
|
585
759
|
walkAST(ast, (node) => {
|
|
586
760
|
if (node.type !== 'VariableDeclaration') return;
|
|
587
761
|
for (const decl of node.declarations) {
|
|
@@ -765,6 +939,10 @@ function findTaintedArgument(args, taintedNames) {
|
|
|
765
939
|
if (prop.value && prop.value.type === 'Identifier' && taintedNames.has(prop.value.name)) {
|
|
766
940
|
return prop.value.name;
|
|
767
941
|
}
|
|
942
|
+
// Spread: { ...data }
|
|
943
|
+
if (prop.type === 'SpreadElement' && prop.argument?.type === 'Identifier' && taintedNames.has(prop.argument.name)) {
|
|
944
|
+
return prop.argument.name;
|
|
945
|
+
}
|
|
768
946
|
}
|
|
769
947
|
}
|
|
770
948
|
}
|
|
@@ -889,6 +1067,8 @@ function resolveLocal(fileDir, spec, packagePath) {
|
|
|
889
1067
|
const abs = path.resolve(fileDir, spec);
|
|
890
1068
|
if (isFileExists(abs)) return toRel(abs, packagePath);
|
|
891
1069
|
if (isFileExists(abs + '.js')) return toRel(abs + '.js', packagePath);
|
|
1070
|
+
if (isFileExists(abs + '.mjs')) return toRel(abs + '.mjs', packagePath);
|
|
1071
|
+
if (isFileExists(abs + '.cjs')) return toRel(abs + '.cjs', packagePath);
|
|
892
1072
|
if (isFileExists(path.join(abs, 'index.js'))) return toRel(path.join(abs, 'index.js'), packagePath);
|
|
893
1073
|
return null;
|
|
894
1074
|
}
|
package/src/scoring.js
CHANGED
|
@@ -111,6 +111,8 @@ const FP_COUNT_THRESHOLDS = {
|
|
|
111
111
|
module_compile_dynamic: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
|
|
112
112
|
module_compile: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
|
|
113
113
|
zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' },
|
|
114
|
+
// Build tools (webpack, jest) legitimately use vm.runInThisContext for module evaluation
|
|
115
|
+
vm_code_execution: { maxCount: 3, from: 'HIGH', to: 'LOW' },
|
|
114
116
|
// P4: plugin loaders legitimately use many dynamic imports (webpack, eslint, knex, gatsby)
|
|
115
117
|
dynamic_import: { maxCount: 5, from: 'HIGH', to: 'LOW' },
|
|
116
118
|
// P4: hash algorithms contain bit manipulation that triggers obfuscation heuristics
|
|
@@ -194,7 +196,12 @@ function applyFPReductions(threats, reachableFiles, packageName) {
|
|
|
194
196
|
// Complex apps (SMTP, monitoring) have 50-80% dataflow findings — still downgrade.
|
|
195
197
|
// But if dataflow is >80% of ALL findings, it may be real targeted exfiltration.
|
|
196
198
|
// (Audit fix: full bypass was exploitable — 4+ dataflow patterns = all LOW.)
|
|
197
|
-
|
|
199
|
+
// vm_code_execution: full bypass — packages with only vm.Script calls (cassandra-driver,
|
|
200
|
+
// webpack, jest) are legitimate. Real malware using vm always has other signals
|
|
201
|
+
// (network, fs, obfuscation). The >3 count threshold is sufficient protection.
|
|
202
|
+
if (typeRatio < 0.5 ||
|
|
203
|
+
(t.type === 'suspicious_dataflow' && typeRatio < 0.8) ||
|
|
204
|
+
t.type === 'vm_code_execution') {
|
|
198
205
|
t.severity = rule.to;
|
|
199
206
|
}
|
|
200
207
|
}
|
package/src/shared/download.js
CHANGED
|
@@ -196,7 +196,7 @@ function extractTarGz(tgzPath, destDir) {
|
|
|
196
196
|
const tgzDir = path.dirname(path.resolve(tgzPath));
|
|
197
197
|
const tgzName = path.basename(tgzPath);
|
|
198
198
|
const relDest = path.relative(tgzDir, path.resolve(destDir)) || '.';
|
|
199
|
-
execFileSync('tar', ['xzf', tgzName, '-C', relDest], { cwd: tgzDir, timeout: 60_000, stdio: 'pipe' });
|
|
199
|
+
execFileSync('tar', ['xzf', tgzName, '-C', relDest, '--no-same-owner'], { cwd: tgzDir, timeout: 60_000, stdio: 'pipe' });
|
|
200
200
|
// npm tarballs extract into a package/ subdirectory; detect it
|
|
201
201
|
const packageSubdir = path.join(destDir, 'package');
|
|
202
202
|
try {
|
package/src/utils.js
CHANGED
|
@@ -215,7 +215,12 @@ function getCallName(node) {
|
|
|
215
215
|
return node.callee.name;
|
|
216
216
|
}
|
|
217
217
|
if (node.callee.type === 'MemberExpression' && node.callee.property) {
|
|
218
|
-
|
|
218
|
+
// Batch 2: handle bracket notation cp['exec']('cmd') — computed property with string literal
|
|
219
|
+
if (node.callee.computed && node.callee.property.type === 'Literal'
|
|
220
|
+
&& typeof node.callee.property.value === 'string') {
|
|
221
|
+
return node.callee.property.value;
|
|
222
|
+
}
|
|
223
|
+
return node.callee.property.name || '';
|
|
219
224
|
}
|
|
220
225
|
return '';
|
|
221
226
|
}
|