muaddib-scanner 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.fr.md +33 -8
  2. package/README.md +33 -8
  3. package/assets/logo2removebg.png +0 -0
  4. package/bin/muaddib.js +9 -0
  5. package/datasets/adversarial/README.md +23 -0
  6. package/datasets/adversarial/ai-agent-weaponization/index.js +5 -0
  7. package/datasets/adversarial/ai-agent-weaponization/package.json +9 -0
  8. package/datasets/adversarial/ai-agent-weaponization/setup.js +83 -0
  9. package/datasets/adversarial/ai-config-injection/.cursorrules +36 -0
  10. package/datasets/adversarial/ai-config-injection/index.js +16 -0
  11. package/datasets/adversarial/ai-config-injection/package.json +8 -0
  12. package/datasets/adversarial/browser-api-hook/index.js +66 -0
  13. package/datasets/adversarial/browser-api-hook/package.json +6 -0
  14. package/datasets/adversarial/bun-runtime-evasion/bun_environment.js +23 -0
  15. package/datasets/adversarial/bun-runtime-evasion/package.json +9 -0
  16. package/datasets/adversarial/bun-runtime-evasion/setup.js +10 -0
  17. package/datasets/adversarial/ci-trigger-exfil/index.js +17 -0
  18. package/datasets/adversarial/ci-trigger-exfil/package.json +9 -0
  19. package/datasets/adversarial/conditional-chain/index.js +14 -0
  20. package/datasets/adversarial/conditional-chain/package.json +9 -0
  21. package/datasets/adversarial/crypto-wallet-harvest/index.js +44 -0
  22. package/datasets/adversarial/crypto-wallet-harvest/package.json +6 -0
  23. package/datasets/adversarial/dead-mans-switch/index.js +35 -0
  24. package/datasets/adversarial/dead-mans-switch/package.json +9 -0
  25. package/datasets/adversarial/delayed-exfil/index.js +6 -0
  26. package/datasets/adversarial/delayed-exfil/package.json +6 -0
  27. package/datasets/adversarial/detached-background/launcher.js +11 -0
  28. package/datasets/adversarial/detached-background/package.json +9 -0
  29. package/datasets/adversarial/detached-background/worker.js +26 -0
  30. package/datasets/adversarial/discord-webhook-exfil/index.js +95 -0
  31. package/datasets/adversarial/discord-webhook-exfil/package.json +9 -0
  32. package/datasets/adversarial/dns-chunk-exfil/index.js +10 -0
  33. package/datasets/adversarial/dns-chunk-exfil/package.json +6 -0
  34. package/datasets/adversarial/docker-aware/index.js +10 -0
  35. package/datasets/adversarial/docker-aware/package.json +6 -0
  36. package/datasets/adversarial/double-base64-exfil/index.js +11 -0
  37. package/datasets/adversarial/double-base64-exfil/package.json +9 -0
  38. package/datasets/adversarial/dynamic-import/index.js +21 -0
  39. package/datasets/adversarial/dynamic-import/package.json +9 -0
  40. package/datasets/adversarial/dynamic-require/index.js +3 -0
  41. package/datasets/adversarial/dynamic-require/package.json +9 -0
  42. package/datasets/adversarial/fake-captcha-fingerprint/index.js +64 -0
  43. package/datasets/adversarial/fake-captcha-fingerprint/package.json +9 -0
  44. package/datasets/adversarial/gh-cli-token-steal/index.js +31 -0
  45. package/datasets/adversarial/gh-cli-token-steal/package.json +6 -0
  46. package/datasets/adversarial/github-exfil/index.js +33 -0
  47. package/datasets/adversarial/github-exfil/package.json +9 -0
  48. package/datasets/adversarial/iife-exfil/index.js +17 -0
  49. package/datasets/adversarial/iife-exfil/package.json +9 -0
  50. package/datasets/adversarial/nested-payload/index.js +3 -0
  51. package/datasets/adversarial/nested-payload/package.json +9 -0
  52. package/datasets/adversarial/nested-payload/utils/helper.js +6 -0
  53. package/datasets/adversarial/nested-payload/utils/lib/format.js +23 -0
  54. package/datasets/adversarial/postinstall-download/package.json +8 -0
  55. package/datasets/adversarial/preinstall-background-fork/bootstrap.js +16 -0
  56. package/datasets/adversarial/preinstall-background-fork/index.js +2 -0
  57. package/datasets/adversarial/preinstall-background-fork/package.json +9 -0
  58. package/datasets/adversarial/preinstall-background-fork/stealer.js +67 -0
  59. package/datasets/adversarial/preinstall-exec/package.json +9 -0
  60. package/datasets/adversarial/preinstall-exec/steal.js +24 -0
  61. package/datasets/adversarial/proxy-env-intercept/index.js +33 -0
  62. package/datasets/adversarial/proxy-env-intercept/package.json +9 -0
  63. package/datasets/adversarial/pyinstaller-dropper/index.js +25 -0
  64. package/datasets/adversarial/pyinstaller-dropper/package.json +9 -0
  65. package/datasets/adversarial/rdd-zero-deps/index.js +32 -0
  66. package/datasets/adversarial/rdd-zero-deps/init.js +15 -0
  67. package/datasets/adversarial/rdd-zero-deps/package.json +11 -0
  68. package/datasets/adversarial/remote-dynamic-dependency/index.js +15 -0
  69. package/datasets/adversarial/remote-dynamic-dependency/package.json +7 -0
  70. package/datasets/adversarial/self-hosted-runner-backdoor/index.js +28 -0
  71. package/datasets/adversarial/self-hosted-runner-backdoor/package.json +9 -0
  72. package/datasets/adversarial/silent-error-swallow/index.js +32 -0
  73. package/datasets/adversarial/silent-error-swallow/package.json +6 -0
  74. package/datasets/adversarial/staged-fetch/index.js +9 -0
  75. package/datasets/adversarial/staged-fetch/package.json +6 -0
  76. package/datasets/adversarial/string-concat-obfuscation/index.js +6 -0
  77. package/datasets/adversarial/string-concat-obfuscation/package.json +6 -0
  78. package/datasets/adversarial/template-literal-obfuscation/index.js +4 -0
  79. package/datasets/adversarial/template-literal-obfuscation/package.json +9 -0
  80. package/datasets/adversarial/triple-base64-github-push/index.js +38 -0
  81. package/datasets/adversarial/triple-base64-github-push/package.json +9 -0
  82. package/datasets/adversarial/websocket-exfil/index.js +34 -0
  83. package/datasets/adversarial/websocket-exfil/package.json +9 -0
  84. package/datasets/benign/README.md +20 -0
  85. package/datasets/benign/packages-npm.txt +98 -0
  86. package/datasets/benign/packages-pypi.txt +49 -0
  87. package/metrics/v2.1.5.json +753 -0
  88. package/metrics/v2.2.0.json +753 -0
  89. package/nul +0 -5
  90. package/package.json +1 -1
  91. package/src/commands/evaluate.js +270 -0
  92. package/src/index.js +12 -5
  93. package/src/ioc/bootstrap.js +1 -0
  94. package/src/response/playbooks.js +66 -0
  95. package/src/rules/index.js +181 -0
  96. package/src/scanner/ai-config.js +183 -0
  97. package/src/scanner/ast.js +496 -2
  98. package/src/scanner/dataflow.js +147 -16
  99. package/src/scanner/package.js +3 -1
  100. package/src/utils.js +10 -3
@@ -28,6 +28,13 @@ async function analyzeDataFlow(targetPath) {
28
28
  } catch {
29
29
  continue;
30
30
  }
31
+
32
+ // Respect // muaddib-ignore directive in first 5 lines (like eslint-disable)
33
+ const headerLines = content.slice(0, 1024).split('\n').slice(0, 5);
34
+ if (headerLines.some(line => line.includes('muaddib-ignore'))) {
35
+ continue;
36
+ }
37
+
31
38
  const fileThreats = analyzeFile(content, file, targetPath);
32
39
  threats.push(...fileThreats);
33
40
  }
@@ -53,14 +60,25 @@ function analyzeFile(content, filePath, basePath) {
53
60
  const sources = [];
54
61
  const sinks = [];
55
62
 
63
+ // Track variables assigned from sensitive path expressions
64
+ const sensitivePathVars = new Set();
65
+
56
66
  walk.simple(ast, {
67
+ VariableDeclarator(node) {
68
+ if (node.id?.type === 'Identifier' && node.init) {
69
+ if (containsSensitiveLiteral(node.init)) {
70
+ sensitivePathVars.add(node.id.name);
71
+ }
72
+ }
73
+ },
74
+
57
75
  CallExpression(node) {
58
76
  const callName = getCallName(node);
59
-
77
+
60
78
  if (callName === 'readFileSync' || callName === 'readFile' ||
61
79
  callName === 'fs.readFileSync' || callName === 'fs.readFile') {
62
80
  const arg = node.arguments[0];
63
- if (arg && isCredentialPath(arg)) {
81
+ if (arg && isCredentialPath(arg, sensitivePathVars)) {
64
82
  sources.push({
65
83
  type: 'credential_read',
66
84
  name: callName,
@@ -89,6 +107,65 @@ function analyzeFile(content, filePath, basePath) {
89
107
  }
90
108
  }
91
109
  }
110
+
111
+ // os.hostname(), os.networkInterfaces(), os.userInfo() as fingerprint sources
112
+ if (node.callee.type === 'MemberExpression') {
113
+ const obj = node.callee.object;
114
+ const prop = node.callee.property;
115
+ if (obj?.type === 'Identifier' && obj.name === 'os' && prop?.type === 'Identifier') {
116
+ if (['hostname', 'networkInterfaces', 'userInfo', 'cpus', 'totalmem', 'platform', 'arch', 'homedir'].includes(prop.name)) {
117
+ sources.push({
118
+ type: 'fingerprint_read',
119
+ name: `os.${prop.name}`,
120
+ line: node.loc?.start?.line
121
+ });
122
+ }
123
+ }
124
+ }
125
+
126
+ // fs.readdirSync as credential source when reading sensitive directories
127
+ if (node.callee.type === 'MemberExpression') {
128
+ const prop = node.callee.property;
129
+ if (prop?.type === 'Identifier' && (prop.name === 'readdirSync' || prop.name === 'readdir')) {
130
+ const arg = node.arguments[0];
131
+ if (arg && isCredentialPath(arg, sensitivePathVars)) {
132
+ sources.push({
133
+ type: 'credential_read',
134
+ name: prop.name,
135
+ line: node.loc?.start?.line
136
+ });
137
+ }
138
+ }
139
+ }
140
+
141
+ // MemberExpression network sinks: http.request, https.get, dns.resolve, net.connect, etc.
142
+ if (node.callee.type === 'MemberExpression') {
143
+ const obj = node.callee.object;
144
+ const prop = node.callee.property;
145
+ if (obj.type === 'Identifier' && prop.type === 'Identifier') {
146
+ // DNS resolution as exfiltration sink
147
+ if (obj.name === 'dns' && ['resolve', 'lookup', 'resolve4', 'resolve6'].includes(prop.name)) {
148
+ sinks.push({ type: 'network_send', name: `dns.${prop.name}`, line: node.loc?.start?.line });
149
+ }
150
+ // HTTP/HTTPS request/get as network sink
151
+ if ((obj.name === 'http' || obj.name === 'https') && ['request', 'get'].includes(prop.name)) {
152
+ sinks.push({ type: 'network_send', name: `${obj.name}.${prop.name}`, line: node.loc?.start?.line });
153
+ }
154
+ // net.connect / net.createConnection / tls.connect as network sink
155
+ if ((obj.name === 'net' || obj.name === 'tls') && ['connect', 'createConnection'].includes(prop.name)) {
156
+ sinks.push({ type: 'network_send', name: `${obj.name}.${prop.name}`, line: node.loc?.start?.line });
157
+ }
158
+ }
159
+ }
160
+
161
+ // Track eval calls for staged payload detection
162
+ if (callName === 'eval') {
163
+ sinks.push({
164
+ type: 'eval_exec',
165
+ name: 'eval',
166
+ line: node.loc?.start?.line
167
+ });
168
+ }
92
169
  },
93
170
 
94
171
  MemberExpression(node) {
@@ -108,6 +185,18 @@ function analyzeFile(content, filePath, basePath) {
108
185
  }
109
186
  });
110
187
 
188
+ // Detect staged payload: network fetch + eval in same file (no credential source needed)
189
+ const hasNetworkSink = sinks.some(s => s.type === 'network_send' || s.type === 'exec_network');
190
+ const hasEvalSink = sinks.some(s => s.type === 'eval_exec');
191
+ if (hasNetworkSink && hasEvalSink) {
192
+ threats.push({
193
+ type: 'staged_payload',
194
+ severity: 'CRITICAL',
195
+ message: 'Network fetch + eval() in same file (staged payload execution).',
196
+ file: path.relative(basePath, filePath)
197
+ });
198
+ }
199
+
111
200
  if (sources.length > 0 && sinks.length > 0) {
112
201
  // Determine severity by scope proximity: if source and sink are < 50 lines apart -> CRITICAL, else HIGH
113
202
  let severity = 'HIGH';
@@ -132,23 +221,65 @@ function analyzeFile(content, filePath, basePath) {
132
221
  return threats;
133
222
  }
134
223
 
135
- function isCredentialPath(arg) {
224
+ const SENSITIVE_PATH_PATTERNS = [
225
+ '.npmrc', '.ssh', '.aws', '.gitconfig', '.env',
226
+ '/etc/passwd', '/etc/shadow', '/etc/hosts',
227
+ '.ethereum', '.electrum', '.config/solana', '.exodus',
228
+ '.atomic', '.metamask', '.ledger-live', '.trezor',
229
+ '.bitcoin', '.monero', '.gnupg'
230
+ ];
231
+
232
+ function isSensitivePath(val) {
233
+ const lower = val.toLowerCase();
234
+ return SENSITIVE_PATH_PATTERNS.some(p => lower.includes(p));
235
+ }
236
+
237
+ /**
238
+ * Checks if an expression tree contains any sensitive path literal.
239
+ * Used to determine if a variable assignment should be tracked.
240
+ */
241
+ function containsSensitiveLiteral(node) {
242
+ if (!node || typeof node !== 'object') return false;
243
+ if (node.type === 'Literal' && typeof node.value === 'string') {
244
+ return isSensitivePath(node.value);
245
+ }
246
+ if (node.type === 'TemplateLiteral') {
247
+ const quasiText = (node.quasis || []).map(q => q.value.raw).join('');
248
+ return isSensitivePath(quasiText);
249
+ }
250
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
251
+ return containsSensitiveLiteral(node.left) || containsSensitiveLiteral(node.right);
252
+ }
253
+ if (node.type === 'CallExpression' && node.arguments) {
254
+ return node.arguments.some(a => containsSensitiveLiteral(a));
255
+ }
256
+ return false;
257
+ }
258
+
259
+ function isCredentialPath(arg, sensitivePathVars) {
136
260
  if (arg.type === 'Literal' && typeof arg.value === 'string') {
137
- const val = arg.value.toLowerCase();
138
- return val.includes('.npmrc') ||
139
- val.includes('.ssh') ||
140
- val.includes('.aws') ||
141
- val.includes('.gitconfig') ||
142
- val.includes('.env');
261
+ return isSensitivePath(arg.value);
143
262
  }
144
263
  if (arg.type === 'TemplateLiteral') {
145
- // Check quasis (string parts) of template literal
146
- const quasiText = (arg.quasis || []).map(q => q.value.raw).join('').toLowerCase();
147
- return quasiText.includes('.npmrc') ||
148
- quasiText.includes('.ssh') ||
149
- quasiText.includes('.aws') ||
150
- quasiText.includes('.gitconfig') ||
151
- quasiText.includes('.env');
264
+ const quasiText = (arg.quasis || []).map(q => q.value.raw).join('');
265
+ return isSensitivePath(quasiText);
266
+ }
267
+ // Handle string concatenation: homedir() + '/.npmrc'
268
+ if (arg.type === 'BinaryExpression' && arg.operator === '+') {
269
+ return isCredentialPath(arg.left, sensitivePathVars) || isCredentialPath(arg.right, sensitivePathVars);
270
+ }
271
+ // Handle variable references: fs.readFileSync(npmrcPath) where npmrcPath was assigned a sensitive path
272
+ if (arg.type === 'Identifier' && sensitivePathVars && sensitivePathVars.has(arg.name)) {
273
+ return true;
274
+ }
275
+ // Handle path.join(dir, '.npmrc') or path.join(sshDir, 'id_rsa') where sshDir is tracked
276
+ if (arg.type === 'CallExpression' && arg.callee.type === 'MemberExpression') {
277
+ const obj = arg.callee.object;
278
+ const prop = arg.callee.property;
279
+ if (obj.type === 'Identifier' && obj.name === 'path' &&
280
+ prop.type === 'Identifier' && (prop.name === 'join' || prop.name === 'resolve')) {
281
+ return arg.arguments.some(a => isCredentialPath(a, sensitivePathVars));
282
+ }
152
283
  }
153
284
  return false;
154
285
  }
@@ -19,7 +19,9 @@ const DANGEROUS_PATTERNS = [
19
19
  { pattern: /\.npmrc/, name: 'npmrc_access' },
20
20
  { pattern: /GITHUB_TOKEN/, name: 'github_token_access' },
21
21
  { pattern: /AWS_/, name: 'aws_credential_access' },
22
- { pattern: /base64/, name: 'base64_encoding' }
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' }
23
25
  ];
24
26
 
25
27
  const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf']);
package/src/utils.js CHANGED
@@ -12,9 +12,11 @@ const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
12
12
  * Merged into every findFiles() call on top of the caller's excludedDirs.
13
13
  */
14
14
  let _extraExcludedDirs = [];
15
+ let _scanRoot = '';
15
16
 
16
- function setExtraExcludes(dirs) {
17
+ function setExtraExcludes(dirs, scanRoot) {
17
18
  _extraExcludedDirs = Array.isArray(dirs) ? dirs : [];
19
+ _scanRoot = scanRoot || '';
18
20
  }
19
21
 
20
22
  function getExtraExcludes() {
@@ -91,10 +93,15 @@ function findFiles(dir, options = {}) {
91
93
  }
92
94
 
93
95
  for (const item of items) {
94
- if (allExcludedDirs.includes(item)) continue;
95
-
96
96
  const fullPath = path.join(dir, item);
97
97
 
98
+ // Check both bare name ("tests") and relative path ("src/scanner")
99
+ if (allExcludedDirs.includes(item)) continue;
100
+ if (_extraExcludedDirs.length > 0 && _scanRoot) {
101
+ const rel = path.relative(_scanRoot, fullPath).replace(/\\/g, '/');
102
+ if (_extraExcludedDirs.some(ex => rel === ex || rel.startsWith(ex + '/'))) continue;
103
+ }
104
+
98
105
  try {
99
106
  const lstat = fs.lstatSync(fullPath);
100
107