muaddib-scanner 2.4.0 → 2.4.2
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/bin/muaddib.js +6 -4
- package/package.json +1 -1
- package/src/response/playbooks.js +5 -0
- package/src/rules/index.js +13 -0
- package/src/sandbox.js +43 -11
- package/src/scanner/ast-detectors.js +37 -0
- package/src/scanner/dataflow.js +263 -5
- package/src/scoring.js +4 -1
package/bin/muaddib.js
CHANGED
|
@@ -620,13 +620,14 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
620
620
|
const packageName = sandboxOpts[0];
|
|
621
621
|
const strict = options.includes('--strict');
|
|
622
622
|
const canary = !options.includes('--no-canary');
|
|
623
|
+
const local = options.includes('--local');
|
|
623
624
|
if (!packageName) {
|
|
624
|
-
console.log('Usage: muaddib sandbox <package-name> [--strict] [--no-canary]');
|
|
625
|
+
console.log('Usage: muaddib sandbox <package-name|path> [--local] [--strict] [--no-canary]');
|
|
625
626
|
process.exit(1);
|
|
626
627
|
}
|
|
627
628
|
|
|
628
629
|
buildSandboxImage()
|
|
629
|
-
.then(() => runSandbox(packageName, { strict, canary }))
|
|
630
|
+
.then(() => runSandbox(packageName, { strict, canary, local }))
|
|
630
631
|
.then((results) => {
|
|
631
632
|
process.exit(results.suspicious ? 1 : 0);
|
|
632
633
|
})
|
|
@@ -639,13 +640,14 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
639
640
|
const packageName = sandboxOpts[0];
|
|
640
641
|
const strict = options.includes('--strict');
|
|
641
642
|
const canary = !options.includes('--no-canary');
|
|
643
|
+
const local = options.includes('--local');
|
|
642
644
|
if (!packageName) {
|
|
643
|
-
console.log('Usage: muaddib sandbox-report <package-name> [--strict] [--no-canary]');
|
|
645
|
+
console.log('Usage: muaddib sandbox-report <package-name|path> [--local] [--strict] [--no-canary]');
|
|
644
646
|
process.exit(1);
|
|
645
647
|
}
|
|
646
648
|
|
|
647
649
|
buildSandboxImage()
|
|
648
|
-
.then(() => runSandbox(packageName, { strict, canary }))
|
|
650
|
+
.then(() => runSandbox(packageName, { strict, canary, local }))
|
|
649
651
|
.then((results) => {
|
|
650
652
|
if (results.raw_report) {
|
|
651
653
|
console.log(generateNetworkReport(results.raw_report));
|
package/package.json
CHANGED
|
@@ -382,6 +382,11 @@ const PLAYBOOKS = {
|
|
|
382
382
|
'Acces a 3+ cles API de providers LLM (OpenAI, Anthropic, Google, etc.). Usage unique = legitime, ' +
|
|
383
383
|
'acces multiples = collecte pour revente ou abus. Verifier si le package a une raison ' +
|
|
384
384
|
'legitime d\'acceder a plusieurs providers. Revoquer les cles exposees si necessaire.',
|
|
385
|
+
|
|
386
|
+
suspicious_domain:
|
|
387
|
+
'Domaine C2 ou d\'exfiltration detecte dans le code source. Ces domaines (oastify.com, webhook.site, ngrok.io, etc.) ' +
|
|
388
|
+
'sont utilises pour recevoir des donnees volees ou relayer des commandes. Verifier si le package a une raison ' +
|
|
389
|
+
'legitime d\'utiliser ce domaine. Bloquer les connexions sortantes vers ce domaine.',
|
|
385
390
|
};
|
|
386
391
|
|
|
387
392
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -1090,6 +1090,19 @@ const RULES = {
|
|
|
1090
1090
|
],
|
|
1091
1091
|
mitre: 'T1552.001'
|
|
1092
1092
|
},
|
|
1093
|
+
|
|
1094
|
+
suspicious_domain: {
|
|
1095
|
+
id: 'MUADDIB-AST-032',
|
|
1096
|
+
name: 'Suspicious C2/Exfiltration Domain',
|
|
1097
|
+
severity: 'HIGH',
|
|
1098
|
+
confidence: 'high',
|
|
1099
|
+
description: 'Domaine C2 ou d\'exfiltration detecte dans le code (oastify.com, burpcollaborator.net, webhook.site, ngrok.io, etc.). Ces domaines sont utilises pour recevoir des donnees volees ou comme relais de commande.',
|
|
1100
|
+
references: [
|
|
1101
|
+
'https://attack.mitre.org/techniques/T1071/001/',
|
|
1102
|
+
'https://portswigger.net/burp/documentation/collaborator'
|
|
1103
|
+
],
|
|
1104
|
+
mitre: 'T1071.001'
|
|
1105
|
+
},
|
|
1093
1106
|
};
|
|
1094
1107
|
|
|
1095
1108
|
function getRule(type) {
|
package/src/sandbox.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { execSync, execFileSync, spawn } = require('child_process');
|
|
2
2
|
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const {
|
|
5
6
|
generateCanaryTokens,
|
|
@@ -125,18 +126,40 @@ async function buildSandboxImage() {
|
|
|
125
126
|
async function runSandbox(packageName, options = {}) {
|
|
126
127
|
const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
|
|
127
128
|
|
|
128
|
-
if (!isDockerAvailable()) {
|
|
129
|
-
console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
|
|
130
|
-
return cleanResult;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
129
|
const strict = options.strict || false;
|
|
134
130
|
const canaryEnabled = options.canary !== false; // enabled by default
|
|
131
|
+
const local = options.local || false;
|
|
135
132
|
const mode = strict ? 'strict' : 'permissive';
|
|
136
133
|
|
|
137
|
-
// Validate
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
// Validate inputs before checking Docker availability
|
|
135
|
+
let localAbsPath = null;
|
|
136
|
+
let displayName = packageName;
|
|
137
|
+
|
|
138
|
+
if (local) {
|
|
139
|
+
localAbsPath = path.resolve(packageName);
|
|
140
|
+
if (!fs.existsSync(localAbsPath)) {
|
|
141
|
+
console.log('[SANDBOX] Local path does not exist: ' + localAbsPath);
|
|
142
|
+
return cleanResult;
|
|
143
|
+
}
|
|
144
|
+
// Read package name for display
|
|
145
|
+
const pkgJsonPath = path.join(localAbsPath, 'package.json');
|
|
146
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
147
|
+
try {
|
|
148
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
149
|
+
displayName = pkg.name || path.basename(localAbsPath);
|
|
150
|
+
} catch { displayName = path.basename(localAbsPath); }
|
|
151
|
+
} else {
|
|
152
|
+
displayName = path.basename(localAbsPath);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
if (!NPM_PACKAGE_REGEX.test(packageName)) {
|
|
156
|
+
console.log('[SANDBOX] Invalid package name: ' + packageName);
|
|
157
|
+
return cleanResult;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!isDockerAvailable()) {
|
|
162
|
+
console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
|
|
140
163
|
return cleanResult;
|
|
141
164
|
}
|
|
142
165
|
|
|
@@ -147,7 +170,7 @@ async function runSandbox(packageName, options = {}) {
|
|
|
147
170
|
canaryTokens = canary.tokens;
|
|
148
171
|
}
|
|
149
172
|
|
|
150
|
-
console.log(`[SANDBOX] Analyzing "${
|
|
173
|
+
console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''})...`);
|
|
151
174
|
|
|
152
175
|
return new Promise((resolve) => {
|
|
153
176
|
let stdout = '';
|
|
@@ -190,8 +213,13 @@ async function runSandbox(packageName, options = {}) {
|
|
|
190
213
|
dockerArgs.push('--read-only');
|
|
191
214
|
|
|
192
215
|
dockerArgs.push('--security-opt', 'no-new-privileges');
|
|
216
|
+
|
|
217
|
+
if (local) {
|
|
218
|
+
dockerArgs.push('-v', `${localAbsPath}:/sandbox/local-pkg:ro`);
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
dockerArgs.push(DOCKER_IMAGE);
|
|
194
|
-
dockerArgs.push(packageName);
|
|
222
|
+
dockerArgs.push(local ? '/sandbox/local-pkg' : packageName);
|
|
195
223
|
dockerArgs.push(mode);
|
|
196
224
|
|
|
197
225
|
const proc = spawn('docker', dockerArgs);
|
|
@@ -262,6 +290,9 @@ async function runSandbox(packageName, options = {}) {
|
|
|
262
290
|
jsonStr = stdout.substring(jsonStart, jsonEnd + 1);
|
|
263
291
|
}
|
|
264
292
|
report = JSON.parse(jsonStr);
|
|
293
|
+
if (local && report) {
|
|
294
|
+
report.package = displayName;
|
|
295
|
+
}
|
|
265
296
|
} catch (e) {
|
|
266
297
|
console.log('[SANDBOX] Failed to parse container output:', e.message);
|
|
267
298
|
resolve(cleanResult);
|
|
@@ -351,8 +382,9 @@ function detectStaticCanaryExfiltration(report) {
|
|
|
351
382
|
for (const file of (report.filesystem?.created || [])) if (file) searchable.push(file);
|
|
352
383
|
for (const proc of (report.processes?.spawned || [])) if (proc.command) searchable.push(proc.command);
|
|
353
384
|
|
|
354
|
-
// Install output
|
|
385
|
+
// Install + entrypoint output
|
|
355
386
|
if (report.install_output) searchable.push(report.install_output);
|
|
387
|
+
if (report.entrypoint_output) searchable.push(report.entrypoint_output);
|
|
356
388
|
|
|
357
389
|
const allOutput = searchable.join('\n');
|
|
358
390
|
|
|
@@ -102,6 +102,18 @@ const GIT_HOOKS = [
|
|
|
102
102
|
'pre-rebase', 'post-rewrite', 'pre-auto-gc'
|
|
103
103
|
];
|
|
104
104
|
|
|
105
|
+
// Suspicious C2/exfiltration domains (HIGH severity)
|
|
106
|
+
const SUSPICIOUS_DOMAINS_HIGH = [
|
|
107
|
+
'oastify.com', 'oast.fun', 'oast.me', 'oast.live',
|
|
108
|
+
'burpcollaborator.net', 'webhook.site', 'pipedream.net',
|
|
109
|
+
'requestbin.com', 'hookbin.com', 'canarytokens.com'
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// Suspicious tunnel/proxy domains (MEDIUM severity)
|
|
113
|
+
const SUSPICIOUS_DOMAINS_MEDIUM = [
|
|
114
|
+
'ngrok.io', 'ngrok-free.app', 'serveo.net', 'localhost.run', 'loca.lt'
|
|
115
|
+
];
|
|
116
|
+
|
|
105
117
|
// LLM API key environment variable names (3+ = harvesting)
|
|
106
118
|
const LLM_API_KEY_VARS = [
|
|
107
119
|
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GOOGLE_API_KEY',
|
|
@@ -945,6 +957,31 @@ function handleLiteral(node, ctx) {
|
|
|
945
957
|
});
|
|
946
958
|
}
|
|
947
959
|
}
|
|
960
|
+
|
|
961
|
+
// Detect suspicious C2/exfiltration domains in string literals
|
|
962
|
+
const lowerVal = node.value.toLowerCase();
|
|
963
|
+
for (const domain of SUSPICIOUS_DOMAINS_HIGH) {
|
|
964
|
+
if (lowerVal.includes(domain)) {
|
|
965
|
+
ctx.threats.push({
|
|
966
|
+
type: 'suspicious_domain',
|
|
967
|
+
severity: 'HIGH',
|
|
968
|
+
message: `Suspicious C2/exfiltration domain "${domain}" found in string literal.`,
|
|
969
|
+
file: ctx.relFile
|
|
970
|
+
});
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
for (const domain of SUSPICIOUS_DOMAINS_MEDIUM) {
|
|
975
|
+
if (lowerVal.includes(domain)) {
|
|
976
|
+
ctx.threats.push({
|
|
977
|
+
type: 'suspicious_domain',
|
|
978
|
+
severity: 'MEDIUM',
|
|
979
|
+
message: `Suspicious tunnel/proxy domain "${domain}" found in string literal.`,
|
|
980
|
+
file: ctx.relFile
|
|
981
|
+
});
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
948
985
|
}
|
|
949
986
|
}
|
|
950
987
|
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -6,6 +6,108 @@ const { getCallName } = require('../utils.js');
|
|
|
6
6
|
const { ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
|
|
7
7
|
const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
|
|
8
8
|
|
|
9
|
+
// Module classification maps for intra-file taint tracking
|
|
10
|
+
const MODULE_SOURCE_METHODS = {
|
|
11
|
+
os: {
|
|
12
|
+
homedir: 'fingerprint_read', hostname: 'fingerprint_read',
|
|
13
|
+
networkInterfaces: 'fingerprint_read', userInfo: 'fingerprint_read',
|
|
14
|
+
platform: 'telemetry_read', arch: 'telemetry_read'
|
|
15
|
+
},
|
|
16
|
+
fs: {
|
|
17
|
+
readFileSync: 'credential_read', readFile: 'credential_read',
|
|
18
|
+
readdirSync: 'credential_read', readdir: 'credential_read'
|
|
19
|
+
},
|
|
20
|
+
child_process: {
|
|
21
|
+
exec: 'command_output', execSync: 'command_output',
|
|
22
|
+
spawn: 'command_output', spawnSync: 'command_output'
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const MODULE_SINK_METHODS = {
|
|
27
|
+
child_process: { exec: 'exec_sink', execSync: 'exec_sink', spawn: 'exec_sink' },
|
|
28
|
+
http: { request: 'network_send', get: 'network_send' },
|
|
29
|
+
https: { request: 'network_send', get: 'network_send' },
|
|
30
|
+
net: { connect: 'network_send', createConnection: 'network_send' },
|
|
31
|
+
tls: { connect: 'network_send', createConnection: 'network_send' },
|
|
32
|
+
dns: { resolve: 'network_send', lookup: 'network_send', resolve4: 'network_send', resolve6: 'network_send', resolveTxt: 'network_send' },
|
|
33
|
+
fs: { writeFileSync: 'file_tamper', writeFile: 'file_tamper' }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// All tracked module names (for filtering in buildTaintMap)
|
|
37
|
+
const TRACKED_MODULES = new Set([
|
|
38
|
+
...Object.keys(MODULE_SOURCE_METHODS),
|
|
39
|
+
...Object.keys(MODULE_SINK_METHODS)
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// Methods that execute commands — used for exec result capture detection
|
|
43
|
+
const EXEC_METHODS = new Set(['exec', 'execSync', 'spawn', 'spawnSync']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pre-pass: builds a taint map from require() assignments.
|
|
47
|
+
* Maps variable names to { source: moduleName, detail: 'module.method' }
|
|
48
|
+
* Only tracks modules in MODULE_SOURCE_METHODS or MODULE_SINK_METHODS.
|
|
49
|
+
*/
|
|
50
|
+
function buildTaintMap(ast) {
|
|
51
|
+
const taintMap = new Map();
|
|
52
|
+
|
|
53
|
+
walk.simple(ast, {
|
|
54
|
+
VariableDeclarator(node) {
|
|
55
|
+
if (!node.init) return;
|
|
56
|
+
|
|
57
|
+
// Pattern: const x = require("os")
|
|
58
|
+
if (node.id.type === 'Identifier' && node.init.type === 'CallExpression') {
|
|
59
|
+
const callee = node.init.callee;
|
|
60
|
+
if (callee.type === 'Identifier' && callee.name === 'require' && node.init.arguments.length > 0) {
|
|
61
|
+
const arg = node.init.arguments[0];
|
|
62
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
|
|
63
|
+
taintMap.set(node.id.name, { source: arg.value, detail: arg.value });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pattern: const { exec, spawn } = require("child_process")
|
|
69
|
+
if (node.id.type === 'ObjectPattern' && node.init.type === 'CallExpression') {
|
|
70
|
+
const callee = node.init.callee;
|
|
71
|
+
if (callee.type === 'Identifier' && callee.name === 'require' && node.init.arguments.length > 0) {
|
|
72
|
+
const arg = node.init.arguments[0];
|
|
73
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string' && TRACKED_MODULES.has(arg.value)) {
|
|
74
|
+
for (const prop of node.id.properties) {
|
|
75
|
+
if (prop.type === 'Property' && prop.value?.type === 'Identifier') {
|
|
76
|
+
const methodName = prop.key?.type === 'Identifier' ? prop.key.name : (prop.key?.value || '');
|
|
77
|
+
taintMap.set(prop.value.name, { source: arg.value, detail: `${arg.value}.${methodName}` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pattern: const e = process.env
|
|
85
|
+
if (node.id.type === 'Identifier' && node.init.type === 'MemberExpression') {
|
|
86
|
+
const obj = node.init.object;
|
|
87
|
+
const prop = node.init.property;
|
|
88
|
+
if (obj?.type === 'Identifier' && obj.name === 'process' &&
|
|
89
|
+
prop?.type === 'Identifier' && prop.name === 'env') {
|
|
90
|
+
taintMap.set(node.id.name, { source: 'process.env', detail: 'process.env' });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Pattern: const h = x.homedir where x is tainted as "os"
|
|
95
|
+
if (node.id.type === 'Identifier' && node.init.type === 'MemberExpression') {
|
|
96
|
+
const obj = node.init.object;
|
|
97
|
+
const prop = node.init.property;
|
|
98
|
+
if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
|
|
99
|
+
const parentTaint = taintMap.get(obj.name);
|
|
100
|
+
if (parentTaint && TRACKED_MODULES.has(parentTaint.source)) {
|
|
101
|
+
taintMap.set(node.id.name, { source: parentTaint.source, detail: `${parentTaint.source}.${prop.name}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return taintMap;
|
|
109
|
+
}
|
|
110
|
+
|
|
9
111
|
async function analyzeDataFlow(targetPath, options = {}) {
|
|
10
112
|
return analyzeWithDeobfuscation(targetPath, analyzeFile, {
|
|
11
113
|
deobfuscate: options.deobfuscate
|
|
@@ -28,6 +130,12 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
28
130
|
// Track variables assigned from sensitive path expressions
|
|
29
131
|
const sensitivePathVars = new Set();
|
|
30
132
|
|
|
133
|
+
// Build taint map for aliased require tracking
|
|
134
|
+
const taintMap = buildTaintMap(ast);
|
|
135
|
+
|
|
136
|
+
// Track exec calls whose result is captured (for command_output source detection)
|
|
137
|
+
const execResultNodes = new Set();
|
|
138
|
+
|
|
31
139
|
walk.simple(ast, {
|
|
32
140
|
VariableDeclarator(node) {
|
|
33
141
|
if (node.id?.type === 'Identifier' && node.init) {
|
|
@@ -48,6 +156,34 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
48
156
|
}
|
|
49
157
|
}
|
|
50
158
|
}
|
|
159
|
+
// Track exec result capture: const output = execSync('cmd')
|
|
160
|
+
if (node.init.type === 'CallExpression') {
|
|
161
|
+
let execName = null;
|
|
162
|
+
const initCallee = node.init.callee;
|
|
163
|
+
if (initCallee?.type === 'Identifier' && EXEC_METHODS.has(initCallee.name)) {
|
|
164
|
+
const taint = taintMap.get(initCallee.name);
|
|
165
|
+
if (taint && taint.source === 'child_process') {
|
|
166
|
+
execName = taint.detail;
|
|
167
|
+
}
|
|
168
|
+
} else if (initCallee?.type === 'MemberExpression' &&
|
|
169
|
+
initCallee.object?.type === 'Identifier' &&
|
|
170
|
+
initCallee.property?.type === 'Identifier' &&
|
|
171
|
+
EXEC_METHODS.has(initCallee.property.name)) {
|
|
172
|
+
const taint = taintMap.get(initCallee.object.name);
|
|
173
|
+
if (taint && taint.source === 'child_process') {
|
|
174
|
+
execName = `child_process.${initCallee.property.name}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (execName) {
|
|
178
|
+
execResultNodes.add(node.init);
|
|
179
|
+
sources.push({
|
|
180
|
+
type: 'command_output',
|
|
181
|
+
name: execName,
|
|
182
|
+
line: node.loc?.start?.line,
|
|
183
|
+
taint_tracked: true
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
51
187
|
}
|
|
52
188
|
},
|
|
53
189
|
|
|
@@ -163,6 +299,94 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
163
299
|
}
|
|
164
300
|
}
|
|
165
301
|
|
|
302
|
+
// Taint resolution: aliased module calls (e.g., const myOs = require("os"); myOs.homedir())
|
|
303
|
+
if (node.callee.type === 'MemberExpression') {
|
|
304
|
+
const obj = node.callee.object;
|
|
305
|
+
const prop = node.callee.property;
|
|
306
|
+
if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
|
|
307
|
+
const taint = taintMap.get(obj.name);
|
|
308
|
+
// Dedup guard: skip when varName === moduleName (already handled by hard-coded checks above)
|
|
309
|
+
if (taint && obj.name !== taint.source) {
|
|
310
|
+
const moduleName = taint.source;
|
|
311
|
+
const methodName = prop.name;
|
|
312
|
+
// Check source methods (skip command_output — handled by VariableDeclarator capture)
|
|
313
|
+
const sourceMethods = MODULE_SOURCE_METHODS[moduleName];
|
|
314
|
+
if (sourceMethods && sourceMethods[methodName] && sourceMethods[methodName] !== 'command_output') {
|
|
315
|
+
sources.push({
|
|
316
|
+
type: sourceMethods[methodName],
|
|
317
|
+
name: `${moduleName}.${methodName}`,
|
|
318
|
+
line: node.loc?.start?.line,
|
|
319
|
+
taint_tracked: true
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
// Check sink methods
|
|
323
|
+
const sinkMethods = MODULE_SINK_METHODS[moduleName];
|
|
324
|
+
if (sinkMethods && sinkMethods[methodName]) {
|
|
325
|
+
sinks.push({
|
|
326
|
+
type: sinkMethods[methodName],
|
|
327
|
+
name: `${moduleName}.${methodName}`,
|
|
328
|
+
line: node.loc?.start?.line,
|
|
329
|
+
taint_tracked: true
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Taint resolution: bare destructured calls (e.g., const { exec } = require("child_process"); exec("cmd"))
|
|
337
|
+
if (node.callee.type === 'Identifier') {
|
|
338
|
+
const taint = taintMap.get(node.callee.name);
|
|
339
|
+
if (taint && taint.detail.includes('.')) {
|
|
340
|
+
const [moduleName, methodName] = taint.detail.split('.');
|
|
341
|
+
// Check sink methods for destructured calls
|
|
342
|
+
const sinkMethods = MODULE_SINK_METHODS[moduleName];
|
|
343
|
+
if (sinkMethods && sinkMethods[methodName]) {
|
|
344
|
+
sinks.push({
|
|
345
|
+
type: sinkMethods[methodName],
|
|
346
|
+
name: `${moduleName}.${methodName}`,
|
|
347
|
+
line: node.loc?.start?.line,
|
|
348
|
+
taint_tracked: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// Check source methods for destructured calls (skip command_output — handled by VariableDeclarator capture)
|
|
352
|
+
const sourceMethods = MODULE_SOURCE_METHODS[moduleName];
|
|
353
|
+
if (sourceMethods && sourceMethods[methodName] && sourceMethods[methodName] !== 'command_output') {
|
|
354
|
+
sources.push({
|
|
355
|
+
type: sourceMethods[methodName],
|
|
356
|
+
name: `${moduleName}.${methodName}`,
|
|
357
|
+
line: node.loc?.start?.line,
|
|
358
|
+
taint_tracked: true
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Exec callback: exec('cmd', (err, stdout) => {...}) — output will be used
|
|
365
|
+
if (!execResultNodes.has(node) && node.arguments.length >= 2) {
|
|
366
|
+
const lastArg = node.arguments[node.arguments.length - 1];
|
|
367
|
+
if (lastArg.type === 'FunctionExpression' || lastArg.type === 'ArrowFunctionExpression') {
|
|
368
|
+
let isExecCb = false;
|
|
369
|
+
if (node.callee?.type === 'Identifier' && EXEC_METHODS.has(node.callee.name)) {
|
|
370
|
+
const taint = taintMap.get(node.callee.name);
|
|
371
|
+
isExecCb = !!(taint && taint.source === 'child_process');
|
|
372
|
+
} else if (node.callee?.type === 'MemberExpression' &&
|
|
373
|
+
node.callee.object?.type === 'Identifier' &&
|
|
374
|
+
node.callee.property?.type === 'Identifier' &&
|
|
375
|
+
EXEC_METHODS.has(node.callee.property.name)) {
|
|
376
|
+
const taint = taintMap.get(node.callee.object.name);
|
|
377
|
+
isExecCb = !!(taint && taint.source === 'child_process');
|
|
378
|
+
}
|
|
379
|
+
if (isExecCb) {
|
|
380
|
+
sources.push({
|
|
381
|
+
type: 'command_output',
|
|
382
|
+
name: 'child_process.exec',
|
|
383
|
+
line: node.loc?.start?.line,
|
|
384
|
+
taint_tracked: true
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
166
390
|
// Track eval calls for staged payload detection
|
|
167
391
|
if (callName === 'eval') {
|
|
168
392
|
sinks.push({
|
|
@@ -174,6 +398,31 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
174
398
|
},
|
|
175
399
|
|
|
176
400
|
MemberExpression(node) {
|
|
401
|
+
// Taint resolution: aliased process.env (e.g., const env = process.env; env.NPM_TOKEN)
|
|
402
|
+
if (node.object?.type === 'Identifier' && node.property) {
|
|
403
|
+
const taint = taintMap.get(node.object.name);
|
|
404
|
+
if (taint && taint.source === 'process.env') {
|
|
405
|
+
if (node.computed) {
|
|
406
|
+
sources.push({
|
|
407
|
+
type: 'env_read',
|
|
408
|
+
name: 'process.env[dynamic]',
|
|
409
|
+
line: node.loc?.start?.line,
|
|
410
|
+
taint_tracked: true
|
|
411
|
+
});
|
|
412
|
+
} else {
|
|
413
|
+
const envVar = node.property?.name || '';
|
|
414
|
+
if (isSensitiveEnv(envVar)) {
|
|
415
|
+
sources.push({
|
|
416
|
+
type: 'env_read',
|
|
417
|
+
name: envVar,
|
|
418
|
+
line: node.loc?.start?.line,
|
|
419
|
+
taint_tracked: true
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
177
426
|
if (
|
|
178
427
|
node.object?.object?.name === 'process' &&
|
|
179
428
|
node.object?.property?.name === 'env'
|
|
@@ -210,6 +459,9 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
210
459
|
}
|
|
211
460
|
});
|
|
212
461
|
|
|
462
|
+
// Check if any source or sink was resolved via taint tracking
|
|
463
|
+
const hasTaintTracked = sources.some(s => s.taint_tracked) || sinks.some(s => s.taint_tracked);
|
|
464
|
+
|
|
213
465
|
// Detect staged payload: network fetch + eval in same file (no credential source needed)
|
|
214
466
|
const hasNetworkSink = sinks.some(s => s.type === 'network_send' || s.type === 'exec_network');
|
|
215
467
|
const hasEvalSink = sinks.some(s => s.type === 'eval_exec');
|
|
@@ -218,12 +470,15 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
218
470
|
type: 'staged_payload',
|
|
219
471
|
severity: 'CRITICAL',
|
|
220
472
|
message: 'Network fetch + eval() in same file (staged payload execution).',
|
|
221
|
-
file: path.relative(basePath, filePath)
|
|
473
|
+
file: path.relative(basePath, filePath),
|
|
474
|
+
...(hasTaintTracked && { taint_tracked: true })
|
|
222
475
|
});
|
|
223
476
|
}
|
|
224
477
|
|
|
225
478
|
// Separate exfiltration sinks from file tampering sinks
|
|
226
|
-
|
|
479
|
+
// When command output is captured, exclude exec_sink (the exec itself is the source, not an exfil sink)
|
|
480
|
+
const hasCommandOutput = sources.some(s => s.type === 'command_output');
|
|
481
|
+
const exfilSinks = sinks.filter(s => s.type !== 'file_tamper' && !(hasCommandOutput && s.type === 'exec_sink'));
|
|
227
482
|
const fileTamperSinks = sinks.filter(s => s.type === 'file_tamper');
|
|
228
483
|
|
|
229
484
|
if (sources.length > 0 && exfilSinks.length > 0) {
|
|
@@ -243,11 +498,13 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
243
498
|
const allTelemetryOnly = sources.every(s => s.type === 'telemetry_read');
|
|
244
499
|
if (allTelemetryOnly && severity === 'CRITICAL') severity = 'HIGH';
|
|
245
500
|
|
|
501
|
+
const sourceDesc = hasCommandOutput ? 'command output' : 'credentials read';
|
|
246
502
|
threats.push({
|
|
247
503
|
type: 'suspicious_dataflow',
|
|
248
504
|
severity: severity,
|
|
249
|
-
message: `Suspicious flow:
|
|
250
|
-
file: path.relative(basePath, filePath)
|
|
505
|
+
message: `Suspicious flow: ${sourceDesc} (${sources.map(s => s.name).join(', ')}) + network send (${exfilSinks.map(s => s.name).join(', ')})`,
|
|
506
|
+
file: path.relative(basePath, filePath),
|
|
507
|
+
...(hasTaintTracked && { taint_tracked: true })
|
|
251
508
|
});
|
|
252
509
|
}
|
|
253
510
|
|
|
@@ -257,7 +514,8 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
257
514
|
type: 'credential_tampering',
|
|
258
515
|
severity: 'CRITICAL',
|
|
259
516
|
message: `Cache poisoning: sensitive data access (${sources.map(s => s.name).join(', ')}) + write to sensitive path (${fileTamperSinks.map(s => s.name).join(', ')})`,
|
|
260
|
-
file: path.relative(basePath, filePath)
|
|
517
|
+
file: path.relative(basePath, filePath),
|
|
518
|
+
...(hasTaintTracked && { taint_tracked: true })
|
|
261
519
|
});
|
|
262
520
|
}
|
|
263
521
|
|
package/src/scoring.js
CHANGED
|
@@ -149,7 +149,10 @@ const BENIGN_PACKAGE_WHITELIST = new Set([
|
|
|
149
149
|
'blessed', // module._compile for terminal capabilities (module_compile FP)
|
|
150
150
|
'sharp', // native bindings with dynamic require + postinstall (lifecycle FP)
|
|
151
151
|
'forever', // process manager: detached spawn + HOME config access (dataflow FP)
|
|
152
|
-
'start-server-and-test' // curl/wget in test scripts, not install hooks (lifecycle FP)
|
|
152
|
+
'start-server-and-test', // curl/wget in test scripts, not install hooks (lifecycle FP)
|
|
153
|
+
'ultra-runner', // aliased fs.readFileSync in pnp.js + dynamic require (taint-tracked dataflow FP)
|
|
154
|
+
'node-gyp', // aliased child_process.spawn in node-gyp.js + env access (taint-tracked dataflow FP)
|
|
155
|
+
'graceful-fs' // aliased fs.readFile/readdir/writeFile monkey-patching (taint-tracked credential_tampering FP)
|
|
153
156
|
]);
|
|
154
157
|
|
|
155
158
|
// Threat types never affected by benign package whitelist (real compromise indicators)
|