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.
@@ -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
- * Clean a version specifier to extract the primary version number.
31
- * Handles: ^1.0.0, ~1.0.0, >=1.0.0, >=1.0.0,<2.0.0, git URLs, etc.
32
- * @param {string} versionSpec - Raw version from package.json
33
- * @returns {string} Cleaned version or original string
34
- */
35
- function cleanVersionSpec(versionSpec) {
36
- if (!versionSpec || typeof versionSpec !== 'string') return '';
37
- // Skip git URLs, file paths, URLs entirely (not matchable to IOC versions)
38
- if (/^(git[+:]|github:|https?:|file:|\/)/.test(versionSpec)) return '';
39
- // Handle range specifiers like ">=1.0.0,<2.0.0" — extract the first version
40
- const rangeMatch = versionSpec.match(/[\^~>=<!\s]*(\d+\.\d+[.\d-a-zA-Z]*)/);
41
- return rangeMatch ? rangeMatch[1] : versionSpec.replace(/^[\^~>=<! ]+/, '');
42
- }
43
-
44
- async function scanPackageJson(targetPath) {
45
- const threats = [];
46
- const pkgPath = path.join(targetPath, 'package.json');
47
-
48
- if (!fs.existsSync(pkgPath)) {
49
- return threats;
50
- }
51
-
52
- let pkg;
53
- try {
54
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
55
- } catch (e) {
56
- console.log('[WARN] Failed to parse package.json: ' + e.message);
57
- return threats;
58
- }
59
- const scripts = pkg.scripts || {};
60
-
61
- // Scan lifecycle scripts
62
- for (const scriptName of SUSPICIOUS_SCRIPTS) {
63
- if (scripts[scriptName]) {
64
- const scriptContent = scripts[scriptName];
65
-
66
- threats.push({
67
- type: 'lifecycle_script',
68
- severity: 'MEDIUM',
69
- message: `Script "${scriptName}" detected. Common attack vector.`,
70
- file: 'package.json'
71
- });
72
-
73
- for (const { pattern, name } of DANGEROUS_PATTERNS) {
74
- if (pattern.test(scriptContent)) {
75
- threats.push({
76
- type: name,
77
- severity: 'HIGH',
78
- message: `Dangerous pattern "${name}" in script "${scriptName}".`,
79
- file: 'package.json'
80
- });
81
- }
82
- }
83
-
84
- // Escalate: lifecycle script (preinstall/install/postinstall) + shell pipe → CRITICAL
85
- if (['preinstall', 'install', 'postinstall'].includes(scriptName)) {
86
- if (/curl\s.*\|\s*(sh|bash)\b/.test(scriptContent) ||
87
- /wget\s.*\|\s*(sh|bash)\b/.test(scriptContent)) {
88
- threats.push({
89
- type: 'lifecycle_shell_pipe',
90
- severity: 'CRITICAL',
91
- message: `Critical: "${scriptName}" pipes remote code to shell — supply chain RCE.`,
92
- file: 'package.json'
93
- });
94
- }
95
- }
96
- }
97
- }
98
-
99
- // Scan declared dependencies against IOCs
100
- let iocs;
101
- try {
102
- iocs = loadCachedIOCs();
103
- } catch (e) {
104
- console.log('[WARN] Failed to load IOCs: ' + e.message);
105
- return threats;
106
- }
107
- const allDeps = {};
108
- const depSources = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies, pkg.peerDependencies];
109
- for (const src of depSources) {
110
- if (!src || typeof src !== 'object') continue;
111
- for (const [key, value] of Object.entries(src)) {
112
- if (!DANGEROUS_KEYS.has(key)) allDeps[key] = value;
113
- }
114
- }
115
- // bundledDependencies is an array of package names, not an object
116
- if (Array.isArray(pkg.bundledDependencies)) {
117
- for (const name of pkg.bundledDependencies) {
118
- if (typeof name === 'string' && !DANGEROUS_KEYS.has(name)) allDeps[name] = allDeps[name] || '*';
119
- }
120
- }
121
-
122
- for (const [depName, depVersion] of Object.entries(allDeps)) {
123
- if (DANGEROUS_KEYS.has(depName)) continue;
124
- // Skip local dependencies (link:, file:, workspace:) — they're local code, not npm packages
125
- if (typeof depVersion === 'string' && /^(link:|file:|workspace:)/.test(depVersion)) continue;
126
- let malicious = null;
127
-
128
- // Use optimized Map for O(1) lookup if available
129
- if (iocs.packagesMap) {
130
- if (iocs.wildcardPackages && iocs.wildcardPackages.has(depName)) {
131
- const pkgList = iocs.packagesMap.get(depName);
132
- malicious = pkgList ? pkgList.find(p => p.version === '*') : null;
133
- } else if (iocs.packagesMap.has(depName)) {
134
- const pkgList = iocs.packagesMap.get(depName);
135
- const cleanVersion = cleanVersionSpec(depVersion);
136
- malicious = pkgList.find(p => p.version === cleanVersion || p.version === depVersion);
137
- }
138
- } else if (iocs.packages) {
139
- // Fallback: linear search for compatibility
140
- malicious = iocs.packages.find(p => {
141
- if (p.name !== depName) return false;
142
- if (p.version === '*') return true;
143
- const cleanVersion = cleanVersionSpec(depVersion);
144
- if (p.version === cleanVersion || p.version === depVersion) return true;
145
- return false;
146
- });
147
- }
148
-
149
- if (malicious) {
150
- threats.push({
151
- type: 'known_malicious_package',
152
- severity: 'CRITICAL',
153
- message: `Malicious dependency declared: ${depName}@${depVersion} (source: ${malicious.source || 'IOC'})`,
154
- file: 'package.json'
155
- });
156
- }
157
- }
158
-
159
- return threats;
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)) {