muaddib-scanner 2.3.1 → 2.3.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/README.md +17 -16
- package/package.json +1 -1
- package/src/commands/evaluate.js +1 -1
- package/src/scanner/dataflow.js +347 -336
- package/src/scanner/entropy.js +246 -242
- package/src/scanner/obfuscation.js +2 -1
- package/src/scanner/package.js +166 -161
- package/src/scoring.js +18 -0
package/src/scanner/package.js
CHANGED
|
@@ -1,162 +1,167 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
4
|
-
|
|
5
|
-
const SUSPICIOUS_SCRIPTS = [
|
|
6
|
-
'preinstall',
|
|
7
|
-
'postinstall',
|
|
8
|
-
'preuninstall',
|
|
9
|
-
'postuninstall',
|
|
10
|
-
'prepare',
|
|
11
|
-
'prepack'
|
|
12
|
-
];
|
|
13
|
-
|
|
14
|
-
const DANGEROUS_PATTERNS = [
|
|
15
|
-
{ pattern: /curl\s+.*\|.*sh/, name: 'curl_pipe_sh' },
|
|
16
|
-
{ pattern: /wget\s+.*\|.*sh/, name: 'wget_pipe_sh' },
|
|
17
|
-
{ pattern: /eval\s*\(/, name: 'eval_usage' },
|
|
18
|
-
{ pattern: /child_process/, name: 'child_process' },
|
|
19
|
-
{ pattern: /\.npmrc/, name: 'npmrc_access' },
|
|
20
|
-
{ pattern: /GITHUB_TOKEN/, name: 'github_token_access' },
|
|
21
|
-
{ pattern: /AWS_/, name: 'aws_credential_access' },
|
|
22
|
-
{ pattern: /base64/, name: 'base64_encoding' },
|
|
23
|
-
{ pattern: /require\s*\(\s*['"]https?['"]\)/, name: 'network_require' },
|
|
24
|
-
{ pattern: /node\s+-e\s/, name: 'node_inline_exec' }
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf']);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
4
|
+
|
|
5
|
+
const SUSPICIOUS_SCRIPTS = [
|
|
6
|
+
'preinstall',
|
|
7
|
+
'postinstall',
|
|
8
|
+
'preuninstall',
|
|
9
|
+
'postuninstall',
|
|
10
|
+
'prepare',
|
|
11
|
+
'prepack'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const DANGEROUS_PATTERNS = [
|
|
15
|
+
{ pattern: /curl\s+.*\|.*sh/, name: 'curl_pipe_sh' },
|
|
16
|
+
{ pattern: /wget\s+.*\|.*sh/, name: 'wget_pipe_sh' },
|
|
17
|
+
{ pattern: /eval\s*\(/, name: 'eval_usage' },
|
|
18
|
+
{ pattern: /child_process/, name: 'child_process' },
|
|
19
|
+
{ pattern: /\.npmrc/, name: 'npmrc_access' },
|
|
20
|
+
{ pattern: /GITHUB_TOKEN/, name: 'github_token_access' },
|
|
21
|
+
{ pattern: /AWS_/, name: 'aws_credential_access' },
|
|
22
|
+
{ pattern: /base64/, name: 'base64_encoding' },
|
|
23
|
+
{ pattern: /require\s*\(\s*['"]https?['"]\)/, name: 'network_require' },
|
|
24
|
+
{ pattern: /node\s+-e\s/, name: 'node_inline_exec' }
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf']);
|
|
28
|
+
const DEP_FP_WHITELIST = new Set(['es5-ext', 'bootstrap-sass']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Clean a version specifier to extract the primary version number.
|
|
32
|
+
* Handles: ^1.0.0, ~1.0.0, >=1.0.0, >=1.0.0,<2.0.0, git URLs, etc.
|
|
33
|
+
* @param {string} versionSpec - Raw version from package.json
|
|
34
|
+
* @returns {string} Cleaned version or original string
|
|
35
|
+
*/
|
|
36
|
+
function cleanVersionSpec(versionSpec) {
|
|
37
|
+
if (!versionSpec || typeof versionSpec !== 'string') return '';
|
|
38
|
+
// Skip git URLs, file paths, URLs entirely (not matchable to IOC versions)
|
|
39
|
+
if (/^(git[+:]|github:|https?:|file:|\/)/.test(versionSpec)) return '';
|
|
40
|
+
// Handle range specifiers like ">=1.0.0,<2.0.0" — extract the first version
|
|
41
|
+
const rangeMatch = versionSpec.match(/[\^~>=<!\s]*(\d+\.\d+[.\d-a-zA-Z]*)/);
|
|
42
|
+
return rangeMatch ? rangeMatch[1] : versionSpec.replace(/^[\^~>=<! ]+/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function scanPackageJson(targetPath) {
|
|
46
|
+
const threats = [];
|
|
47
|
+
const pkgPath = path.join(targetPath, 'package.json');
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(pkgPath)) {
|
|
50
|
+
return threats;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let pkg;
|
|
54
|
+
try {
|
|
55
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.log('[WARN] Failed to parse package.json: ' + e.message);
|
|
58
|
+
return threats;
|
|
59
|
+
}
|
|
60
|
+
const scripts = pkg.scripts || {};
|
|
61
|
+
|
|
62
|
+
// Scan lifecycle scripts
|
|
63
|
+
for (const scriptName of SUSPICIOUS_SCRIPTS) {
|
|
64
|
+
if (scripts[scriptName]) {
|
|
65
|
+
const scriptContent = scripts[scriptName];
|
|
66
|
+
|
|
67
|
+
threats.push({
|
|
68
|
+
type: 'lifecycle_script',
|
|
69
|
+
severity: 'MEDIUM',
|
|
70
|
+
message: `Script "${scriptName}" detected. Common attack vector.`,
|
|
71
|
+
file: 'package.json'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const { pattern, name } of DANGEROUS_PATTERNS) {
|
|
75
|
+
if (pattern.test(scriptContent)) {
|
|
76
|
+
threats.push({
|
|
77
|
+
type: name,
|
|
78
|
+
severity: 'HIGH',
|
|
79
|
+
message: `Dangerous pattern "${name}" in script "${scriptName}".`,
|
|
80
|
+
file: 'package.json'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Escalate: lifecycle script (preinstall/install/postinstall) + shell pipe → CRITICAL
|
|
86
|
+
if (['preinstall', 'install', 'postinstall'].includes(scriptName)) {
|
|
87
|
+
if (/curl\s.*\|\s*(sh|bash)\b/.test(scriptContent) ||
|
|
88
|
+
/wget\s.*\|\s*(sh|bash)\b/.test(scriptContent)) {
|
|
89
|
+
threats.push({
|
|
90
|
+
type: 'lifecycle_shell_pipe',
|
|
91
|
+
severity: 'CRITICAL',
|
|
92
|
+
message: `Critical: "${scriptName}" pipes remote code to shell — supply chain RCE.`,
|
|
93
|
+
file: 'package.json'
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Scan declared dependencies against IOCs
|
|
101
|
+
let iocs;
|
|
102
|
+
try {
|
|
103
|
+
iocs = loadCachedIOCs();
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.log('[WARN] Failed to load IOCs: ' + e.message);
|
|
106
|
+
return threats;
|
|
107
|
+
}
|
|
108
|
+
const allDeps = {};
|
|
109
|
+
const depSources = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies, pkg.peerDependencies];
|
|
110
|
+
for (const src of depSources) {
|
|
111
|
+
if (!src || typeof src !== 'object') continue;
|
|
112
|
+
for (const [key, value] of Object.entries(src)) {
|
|
113
|
+
if (!DANGEROUS_KEYS.has(key)) allDeps[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// bundledDependencies is an array of package names, not an object
|
|
117
|
+
if (Array.isArray(pkg.bundledDependencies)) {
|
|
118
|
+
for (const name of pkg.bundledDependencies) {
|
|
119
|
+
if (typeof name === 'string' && !DANGEROUS_KEYS.has(name)) allDeps[name] = allDeps[name] || '*';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const [depName, depVersion] of Object.entries(allDeps)) {
|
|
124
|
+
if (DANGEROUS_KEYS.has(depName)) continue;
|
|
125
|
+
// Skip local dependencies (link:, file:, workspace:) — they're local code, not npm packages
|
|
126
|
+
if (typeof depVersion === 'string' && /^(link:|file:|workspace:)/.test(depVersion)) continue;
|
|
127
|
+
// Skip npm alias syntax (e.g. "npm:typescript@^3.1.6") — alias name is virtual, not a real package
|
|
128
|
+
if (typeof depVersion === 'string' && depVersion.startsWith('npm:')) continue;
|
|
129
|
+
// Skip known FP packages that share names with malicious IOC entries
|
|
130
|
+
if (DEP_FP_WHITELIST.has(depName)) continue;
|
|
131
|
+
let malicious = null;
|
|
132
|
+
|
|
133
|
+
// Use optimized Map for O(1) lookup if available
|
|
134
|
+
if (iocs.packagesMap) {
|
|
135
|
+
if (iocs.wildcardPackages && iocs.wildcardPackages.has(depName)) {
|
|
136
|
+
const pkgList = iocs.packagesMap.get(depName);
|
|
137
|
+
malicious = pkgList ? pkgList.find(p => p.version === '*') : null;
|
|
138
|
+
} else if (iocs.packagesMap.has(depName)) {
|
|
139
|
+
const pkgList = iocs.packagesMap.get(depName);
|
|
140
|
+
const cleanVersion = cleanVersionSpec(depVersion);
|
|
141
|
+
malicious = pkgList.find(p => p.version === cleanVersion || p.version === depVersion);
|
|
142
|
+
}
|
|
143
|
+
} else if (iocs.packages) {
|
|
144
|
+
// Fallback: linear search for compatibility
|
|
145
|
+
malicious = iocs.packages.find(p => {
|
|
146
|
+
if (p.name !== depName) return false;
|
|
147
|
+
if (p.version === '*') return true;
|
|
148
|
+
const cleanVersion = cleanVersionSpec(depVersion);
|
|
149
|
+
if (p.version === cleanVersion || p.version === depVersion) return true;
|
|
150
|
+
return false;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (malicious) {
|
|
155
|
+
threats.push({
|
|
156
|
+
type: 'known_malicious_package',
|
|
157
|
+
severity: 'CRITICAL',
|
|
158
|
+
message: `Malicious dependency declared: ${depName}@${depVersion} (source: ${malicious.source || 'IOC'})`,
|
|
159
|
+
file: 'package.json'
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return threats;
|
|
165
|
+
}
|
|
166
|
+
|
|
162
167
|
module.exports = { scanPackageJson };
|
package/src/scoring.js
CHANGED
|
@@ -107,6 +107,7 @@ const FP_COUNT_THRESHOLDS = {
|
|
|
107
107
|
suspicious_dataflow: { maxCount: 5, to: 'LOW' },
|
|
108
108
|
obfuscation_detected: { maxCount: 3, to: 'LOW' },
|
|
109
109
|
module_compile_dynamic: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
|
|
110
|
+
module_compile: { maxCount: 3, from: 'CRITICAL', to: 'LOW' },
|
|
110
111
|
zlib_inflate_eval: { maxCount: 2, from: 'CRITICAL', to: 'LOW' }
|
|
111
112
|
};
|
|
112
113
|
|
|
@@ -151,6 +152,13 @@ function applyFPReductions(threats, reachableFiles) {
|
|
|
151
152
|
t.severity = rule.to;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
// require_cache_poison: single hit → HIGH (plugin dedup/hot-reload, not malware)
|
|
156
|
+
// Malware poisons cache repeatedly; a single access is framework behavior
|
|
157
|
+
if (t.type === 'require_cache_poison' && t.severity === 'CRITICAL' &&
|
|
158
|
+
typeCounts.require_cache_poison === 1) {
|
|
159
|
+
t.severity = 'HIGH';
|
|
160
|
+
}
|
|
161
|
+
|
|
154
162
|
// Prototype hook: framework class prototypes → MEDIUM
|
|
155
163
|
// Core Node.js prototypes (http.IncomingMessage, net.Socket) stay CRITICAL
|
|
156
164
|
// Browser/native APIs (globalThis.fetch, XMLHttpRequest) stay HIGH
|
|
@@ -159,6 +167,16 @@ function applyFPReductions(threats, reachableFiles) {
|
|
|
159
167
|
t.severity = 'MEDIUM';
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
// HTTP client prototype whitelist: packages with >20 prototype_hook hits
|
|
171
|
+
// targeting HTTP objects (Request, Response, fetch, etc.) are legitimate HTTP clients
|
|
172
|
+
if (t.type === 'prototype_hook' && (t.severity === 'HIGH' || t.severity === 'CRITICAL') &&
|
|
173
|
+
typeCounts.prototype_hook > 20) {
|
|
174
|
+
const HTTP_PROTO_RE = /\b(Request|Response|fetch|get|post|put|delete|patch|head|options|query|command)\b/i;
|
|
175
|
+
if (HTTP_PROTO_RE.test(t.message)) {
|
|
176
|
+
t.severity = 'MEDIUM';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
// Dist/build/minified files: bundler artifacts get severity downgraded one notch.
|
|
163
181
|
// Real malware injects payloads in source files, not in dist/ output.
|
|
164
182
|
if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
|