muaddib-scanner 2.10.21 → 2.10.22
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/README.md +11 -11
- package/bin/muaddib.js +343 -39
- package/package.json +1 -1
- package/scripts/benchmark.js +326 -0
- package/src/index.js +34 -3
- package/src/ioc/scraper.js +4 -141
- package/src/ioc/updater.js +26 -7
- package/src/response/playbooks.js +42 -0
- package/src/rules/index.js +118 -1
- package/src/scanner/ast-detectors.js +183 -0
- package/src/scanner/ast.js +5 -1
- package/src/scanner/shell.js +5 -5
- package/src/scoring.js +2 -2
- package/src/shared/constants.js +4 -4
- package/src/utils.js +8 -0
|
@@ -698,6 +698,48 @@ const PLAYBOOKS = {
|
|
|
698
698
|
'dans ~/.config/sysmon/ et exfiltre vers checkmarx.zone. ' +
|
|
699
699
|
'Verifier: find $(python -c "import site; print(site.getsitepackages()[0])") -name "*.pth" -exec cat {} \\; ' +
|
|
700
700
|
'Supprimer tout fichier .pth non standard. Rotation des credentials.',
|
|
701
|
+
|
|
702
|
+
// Audit v3 Bypass Playbooks (AST-062 to AST-069)
|
|
703
|
+
reflect_apply_require:
|
|
704
|
+
'CRITIQUE: Reflect.apply(require, null, [module]) detecte — contournement de require() via Reflect API. ' +
|
|
705
|
+
'Aucun package legitime ne charge des modules via Reflect.apply. ' +
|
|
706
|
+
'Supprimer le package. Auditer les modules charges dynamiquement.',
|
|
707
|
+
|
|
708
|
+
finalization_registry_exec:
|
|
709
|
+
'CRITIQUE: FinalizationRegistry avec callback dangereux (child_process/exec/spawn). ' +
|
|
710
|
+
'Le callback s\'execute apres le garbage collection, hors du flux synchrone — evasion sandbox. ' +
|
|
711
|
+
'Aucun usage legitime ne combine FinalizationRegistry avec des appels systeme. Supprimer le package.',
|
|
712
|
+
|
|
713
|
+
function_prototype_constructor:
|
|
714
|
+
'CRITIQUE: Acces au constructeur Function via chaine de prototypes — (function(){}).constructor(code) ' +
|
|
715
|
+
'ou [].constructor.constructor(code). Equivalent a eval() mais invisible aux detections statiques de eval/Function. ' +
|
|
716
|
+
'Supprimer le package. Aucun usage legitime.',
|
|
717
|
+
|
|
718
|
+
prototype_pollution:
|
|
719
|
+
'HAUTE: Pollution de prototype detectee (__proto__, __defineGetter__, __defineSetter__). ' +
|
|
720
|
+
'Peut detourner les proprietes heritees de tous les objets du runtime. ' +
|
|
721
|
+
'Verifier si le code modifie Object.prototype ou des prototypes de classes natives. ' +
|
|
722
|
+
'Si combine avec du code d\'execution dynamique, traiter comme CRITIQUE.',
|
|
723
|
+
|
|
724
|
+
module_wrap_override:
|
|
725
|
+
'CRITIQUE: Module.wrap remplace — la fonction wrapper du module loader est detournee. ' +
|
|
726
|
+
'Tout module charge apres cette modification execute du code injecte. ' +
|
|
727
|
+
'Aucun package legitime ne remplace Module.wrap. Supprimer immediatement.',
|
|
728
|
+
|
|
729
|
+
symbol_property_hiding:
|
|
730
|
+
'HAUTE: Module dangereux cache derriere une propriete Symbol. ' +
|
|
731
|
+
'Invisible a Object.keys(), JSON.stringify() et for...in. Technique anti-forensics. ' +
|
|
732
|
+
'Auditer toutes les proprietes Symbol du module. Supprimer si combine avec child_process/fs.',
|
|
733
|
+
|
|
734
|
+
with_body_dangerous:
|
|
735
|
+
'HAUTE: with() statement avec require/exec/spawn dans le body. ' +
|
|
736
|
+
'Le with() rend les identifiants ambigus, empechant l\'analyse statique. ' +
|
|
737
|
+
'Aucun code moderne legitime n\'utilise with(). Supprimer le package.',
|
|
738
|
+
|
|
739
|
+
require_process_mainmodule:
|
|
740
|
+
'CRITIQUE: require("process").mainModule.require() detecte — contournement de la detection ' +
|
|
741
|
+
'process.mainModule.require() via require("process") au lieu de l\'objet global. ' +
|
|
742
|
+
'Aucun package legitime n\'utilise ce pattern. Supprimer immediatement.',
|
|
701
743
|
};
|
|
702
744
|
|
|
703
745
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -1523,7 +1523,7 @@ const RULES = {
|
|
|
1523
1523
|
id: 'MUADDIB-SHELL-019',
|
|
1524
1524
|
name: 'Python Time Delay Execution',
|
|
1525
1525
|
severity: 'HIGH',
|
|
1526
|
-
confidence:
|
|
1526
|
+
confidence: 'medium',
|
|
1527
1527
|
description: 'Execution Python avec delai time.sleep() >= 100s via child process. Technique d\'evasion sandbox (T1497.003) : le malware attend que la sandbox expire avant d\'executer le payload.',
|
|
1528
1528
|
references: ['https://attack.mitre.org/techniques/T1497/003/'],
|
|
1529
1529
|
mitre: 'T1497.003'
|
|
@@ -1850,6 +1850,104 @@ const RULES = {
|
|
|
1850
1850
|
],
|
|
1851
1851
|
mitre: 'T1071'
|
|
1852
1852
|
},
|
|
1853
|
+
|
|
1854
|
+
// Audit v3 Bypass Detections (AST-062 to AST-069)
|
|
1855
|
+
reflect_apply_require: {
|
|
1856
|
+
id: 'MUADDIB-AST-062',
|
|
1857
|
+
name: 'Reflect.apply(require) Bypass',
|
|
1858
|
+
severity: 'CRITICAL',
|
|
1859
|
+
confidence: 'high',
|
|
1860
|
+
description: 'Reflect.apply(require, null, [module]) detecte — contourne la detection statique de require() en passant par l\'API Reflect. Permet de charger child_process/fs/net sans appel require() direct.',
|
|
1861
|
+
references: [
|
|
1862
|
+
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/apply',
|
|
1863
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1864
|
+
],
|
|
1865
|
+
mitre: 'T1059'
|
|
1866
|
+
},
|
|
1867
|
+
finalization_registry_exec: {
|
|
1868
|
+
id: 'MUADDIB-AST-063',
|
|
1869
|
+
name: 'FinalizationRegistry Deferred Execution',
|
|
1870
|
+
severity: 'CRITICAL',
|
|
1871
|
+
confidence: 'high',
|
|
1872
|
+
description: 'new FinalizationRegistry() avec callback contenant child_process/exec/spawn. Le callback s\'execute apres le garbage collection, hors du flux d\'execution normal — technique d\'evasion sandbox qui differe l\'execution malveillante.',
|
|
1873
|
+
references: [
|
|
1874
|
+
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry',
|
|
1875
|
+
'https://attack.mitre.org/techniques/T1497/003/'
|
|
1876
|
+
],
|
|
1877
|
+
mitre: 'T1497.003'
|
|
1878
|
+
},
|
|
1879
|
+
function_prototype_constructor: {
|
|
1880
|
+
id: 'MUADDIB-AST-064',
|
|
1881
|
+
name: 'Function via Prototype Chain',
|
|
1882
|
+
severity: 'CRITICAL',
|
|
1883
|
+
confidence: 'high',
|
|
1884
|
+
description: '(function(){}).constructor(code) ou [].constructor.constructor(code) detecte — acces au constructeur Function via la chaine de prototypes, contourne les detections de new Function() et eval().',
|
|
1885
|
+
references: [
|
|
1886
|
+
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function',
|
|
1887
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1888
|
+
],
|
|
1889
|
+
mitre: 'T1059'
|
|
1890
|
+
},
|
|
1891
|
+
prototype_pollution: {
|
|
1892
|
+
id: 'MUADDIB-AST-065',
|
|
1893
|
+
name: 'Prototype Pollution',
|
|
1894
|
+
severity: 'HIGH',
|
|
1895
|
+
confidence: 'high',
|
|
1896
|
+
description: '__defineGetter__, __defineSetter__ ou assignation __proto__ detectee — pollution de prototype permettant de detourner les proprietes heritees de tous les objets. Vecteur d\'escalade pour injecter du code dans des chemins d\'execution inattendus.',
|
|
1897
|
+
references: [
|
|
1898
|
+
'https://portswigger.net/web-security/prototype-pollution',
|
|
1899
|
+
'https://attack.mitre.org/techniques/T1574/'
|
|
1900
|
+
],
|
|
1901
|
+
mitre: 'T1574'
|
|
1902
|
+
},
|
|
1903
|
+
module_wrap_override: {
|
|
1904
|
+
id: 'MUADDIB-AST-066',
|
|
1905
|
+
name: 'Module.wrap Override',
|
|
1906
|
+
severity: 'CRITICAL',
|
|
1907
|
+
confidence: 'high',
|
|
1908
|
+
description: 'Module.wrap = ... detecte — remplacement de la fonction wrapper du module loader Node.js. Permet d\'injecter du code dans CHAQUE module charge apres le remplacement, technique de persistence systemique.',
|
|
1909
|
+
references: [
|
|
1910
|
+
'https://nodejs.org/api/modules.html',
|
|
1911
|
+
'https://attack.mitre.org/techniques/T1574/006/'
|
|
1912
|
+
],
|
|
1913
|
+
mitre: 'T1574.006'
|
|
1914
|
+
},
|
|
1915
|
+
symbol_property_hiding: {
|
|
1916
|
+
id: 'MUADDIB-AST-067',
|
|
1917
|
+
name: 'Symbol Property Hiding',
|
|
1918
|
+
severity: 'HIGH',
|
|
1919
|
+
confidence: 'high',
|
|
1920
|
+
description: 'obj[Symbol(...)] = require(module_dangereux) detecte — dissimulation de modules dangereux derriere des proprietes Symbol, invisibles a Object.keys() et JSON.stringify(). Technique anti-forensics.',
|
|
1921
|
+
references: [
|
|
1922
|
+
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol',
|
|
1923
|
+
'https://attack.mitre.org/techniques/T1564/'
|
|
1924
|
+
],
|
|
1925
|
+
mitre: 'T1564'
|
|
1926
|
+
},
|
|
1927
|
+
with_body_dangerous: {
|
|
1928
|
+
id: 'MUADDIB-AST-068',
|
|
1929
|
+
name: 'WithStatement Dangerous Body',
|
|
1930
|
+
severity: 'HIGH',
|
|
1931
|
+
confidence: 'high',
|
|
1932
|
+
description: 'with() statement dont le body contient require/exec/spawn/child_process — injection de scope pour obscurcir les appels dangereux. Le with() rend tous les identifiants ambigus, empechant l\'analyse statique de tracer les appels.',
|
|
1933
|
+
references: [
|
|
1934
|
+
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/with',
|
|
1935
|
+
'https://attack.mitre.org/techniques/T1027/'
|
|
1936
|
+
],
|
|
1937
|
+
mitre: 'T1027'
|
|
1938
|
+
},
|
|
1939
|
+
require_process_mainmodule: {
|
|
1940
|
+
id: 'MUADDIB-AST-069',
|
|
1941
|
+
name: 'require("process").mainModule Bypass',
|
|
1942
|
+
severity: 'CRITICAL',
|
|
1943
|
+
confidence: 'high',
|
|
1944
|
+
description: 'require("process").mainModule.require() detecte — acces indirect au mainModule via require("process") au lieu de l\'objet global process. Contourne la detection de process.mainModule.require() qui ne surveille que l\'identifiant "process".',
|
|
1945
|
+
references: [
|
|
1946
|
+
'https://nodejs.org/api/process.html',
|
|
1947
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1948
|
+
],
|
|
1949
|
+
mitre: 'T1059'
|
|
1950
|
+
},
|
|
1853
1951
|
};
|
|
1854
1952
|
|
|
1855
1953
|
function getRule(type) {
|
|
@@ -1912,4 +2010,23 @@ for (const [, rule] of Object.entries(PARANOID_RULES)) {
|
|
|
1912
2010
|
PARANOID_RULES_BY_ID[rule.id] = rule;
|
|
1913
2011
|
}
|
|
1914
2012
|
|
|
2013
|
+
// Validate all rules at load time
|
|
2014
|
+
const VALID_SEVERITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
|
2015
|
+
const VALID_CONFIDENCES = new Set(['high', 'medium', 'low']);
|
|
2016
|
+
|
|
2017
|
+
for (const [key, rule] of Object.entries(RULES)) {
|
|
2018
|
+
if (!VALID_SEVERITIES.has(rule.severity)) {
|
|
2019
|
+
throw new Error(`Rule "${key}" has invalid severity: ${JSON.stringify(rule.severity)} (expected CRITICAL|HIGH|MEDIUM|LOW)`);
|
|
2020
|
+
}
|
|
2021
|
+
if (!VALID_CONFIDENCES.has(rule.confidence)) {
|
|
2022
|
+
throw new Error(`Rule "${key}" has invalid confidence: ${JSON.stringify(rule.confidence)} (expected high|medium|low)`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
// PARANOID_RULES use a different schema (patterns/message, no confidence field)
|
|
2026
|
+
for (const [key, rule] of Object.entries(PARANOID_RULES)) {
|
|
2027
|
+
if (!VALID_SEVERITIES.has(rule.severity)) {
|
|
2028
|
+
throw new Error(`Paranoid rule "${key}" has invalid severity: ${JSON.stringify(rule.severity)} (expected CRITICAL|HIGH|MEDIUM|LOW)`);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1915
2032
|
module.exports = { RULES, getRule, PARANOID_RULES };
|
|
@@ -921,6 +921,28 @@ function handleCallExpression(node, ctx) {
|
|
|
921
921
|
}
|
|
922
922
|
}
|
|
923
923
|
|
|
924
|
+
// B8: require('process').mainModule.require('child_process') — indirect process access
|
|
925
|
+
// Pattern: require('process').mainModule.require(mod)
|
|
926
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
927
|
+
node.callee.property?.type === 'Identifier' && node.callee.property.name === 'require' &&
|
|
928
|
+
node.callee.object?.type === 'MemberExpression' &&
|
|
929
|
+
node.callee.object.property?.type === 'Identifier' && node.callee.object.property.name === 'mainModule' &&
|
|
930
|
+
node.callee.object.object?.type === 'CallExpression' &&
|
|
931
|
+
getCallName(node.callee.object.object) === 'require' &&
|
|
932
|
+
node.callee.object.object.arguments?.[0]?.type === 'Literal' &&
|
|
933
|
+
node.callee.object.object.arguments[0].value === 'process') {
|
|
934
|
+
const arg = node.arguments?.[0];
|
|
935
|
+
const modName = arg ? extractStringValueDeep(arg) : null;
|
|
936
|
+
const DANGEROUS_MODS = ['child_process', 'fs', 'net', 'dns', 'http', 'https', 'tls'];
|
|
937
|
+
const severity = modName && DANGEROUS_MODS.includes(modName) ? 'CRITICAL' : 'HIGH';
|
|
938
|
+
ctx.threats.push({
|
|
939
|
+
type: 'require_process_mainmodule',
|
|
940
|
+
severity,
|
|
941
|
+
message: `require('process').mainModule.require(${modName ? "'" + modName + "'" : '...'}) — indirect process access bypasses direct process.mainModule detection.`,
|
|
942
|
+
file: ctx.relFile
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
924
946
|
// Detect exec/execSync with dangerous shell commands (direct or via MemberExpression)
|
|
925
947
|
const execName = callName === 'exec' || callName === 'execSync' ? callName : null;
|
|
926
948
|
const memberExec = !execName && node.callee.type === 'MemberExpression' &&
|
|
@@ -2042,6 +2064,22 @@ function handleCallExpression(node, ctx) {
|
|
|
2042
2064
|
});
|
|
2043
2065
|
}
|
|
2044
2066
|
}
|
|
2067
|
+
|
|
2068
|
+
// B3: (function(){}).constructor('code')() — direct prototype chain Function access
|
|
2069
|
+
// Also: [].constructor.constructor, ''.constructor.constructor, (0).constructor.constructor
|
|
2070
|
+
if (obj?.type === 'FunctionExpression' || obj?.type === 'ArrowFunctionExpression' ||
|
|
2071
|
+
obj?.type === 'ArrayExpression' || obj?.type === 'Literal') {
|
|
2072
|
+
if (!hasOnlyStringLiteralArgs(node)) {
|
|
2073
|
+
ctx.hasEvalInFile = true;
|
|
2074
|
+
ctx.hasDynamicExec = true;
|
|
2075
|
+
ctx.threats.push({
|
|
2076
|
+
type: 'function_prototype_constructor',
|
|
2077
|
+
severity: 'CRITICAL',
|
|
2078
|
+
message: `Function constructor via prototype chain: (${obj.type === 'FunctionExpression' ? 'function(){}' : obj.type === 'ArrayExpression' ? '[]' : 'literal'}).constructor(code) — bypasses Function/eval detection.`,
|
|
2079
|
+
file: ctx.relFile
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2045
2083
|
}
|
|
2046
2084
|
|
|
2047
2085
|
// SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
|
|
@@ -2164,9 +2202,37 @@ function handleCallExpression(node, ctx) {
|
|
|
2164
2202
|
file: ctx.relFile
|
|
2165
2203
|
});
|
|
2166
2204
|
}
|
|
2205
|
+
// B1: Reflect.apply(require, null, ['child_process']) — bypasses require() call detection
|
|
2206
|
+
if (target.type === 'Identifier' && target.name === 'require') {
|
|
2207
|
+
const argsArray = node.arguments[2];
|
|
2208
|
+
let modName = null;
|
|
2209
|
+
if (argsArray?.type === 'ArrayExpression' && argsArray.elements.length > 0) {
|
|
2210
|
+
modName = extractStringValueDeep(argsArray.elements[0]);
|
|
2211
|
+
}
|
|
2212
|
+
const severity = modName && ['child_process', 'fs', 'net', 'dns', 'http', 'https'].includes(modName)
|
|
2213
|
+
? 'CRITICAL' : 'HIGH';
|
|
2214
|
+
ctx.threats.push({
|
|
2215
|
+
type: 'reflect_apply_require',
|
|
2216
|
+
severity,
|
|
2217
|
+
message: `Reflect.apply(require, null, [${modName ? "'" + modName + "'" : '...'}]) — indirect require() bypasses static call detection.`,
|
|
2218
|
+
file: ctx.relFile
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2167
2221
|
}
|
|
2168
2222
|
}
|
|
2169
2223
|
|
|
2224
|
+
// B4: __defineGetter__ / __defineSetter__ — prototype pollution via legacy API
|
|
2225
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
2226
|
+
node.callee.property?.type === 'Identifier' &&
|
|
2227
|
+
(node.callee.property.name === '__defineGetter__' || node.callee.property.name === '__defineSetter__')) {
|
|
2228
|
+
ctx.threats.push({
|
|
2229
|
+
type: 'prototype_pollution',
|
|
2230
|
+
severity: 'HIGH',
|
|
2231
|
+
message: `${node.callee.property.name}() called — legacy prototype pollution API can hijack property access on any object.`,
|
|
2232
|
+
file: ctx.relFile
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2170
2236
|
// Batch 1: process.binding('spawn_sync'/'fs') / process._linkedBinding(...)
|
|
2171
2237
|
if (node.callee.type === 'MemberExpression' &&
|
|
2172
2238
|
node.callee.object?.type === 'Identifier' && node.callee.object.name === 'process' &&
|
|
@@ -2334,6 +2400,37 @@ function handleNewExpression(node, ctx) {
|
|
|
2334
2400
|
}
|
|
2335
2401
|
}
|
|
2336
2402
|
}
|
|
2403
|
+
|
|
2404
|
+
// B2: new FinalizationRegistry(callback) — deferred execution after GC
|
|
2405
|
+
// Malicious pattern: callback contains require('child_process') or exec/spawn
|
|
2406
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'FinalizationRegistry' &&
|
|
2407
|
+
node.arguments.length >= 1) {
|
|
2408
|
+
const callback = node.arguments[0];
|
|
2409
|
+
if (callback) {
|
|
2410
|
+
// Check if callback body contains dangerous patterns
|
|
2411
|
+
let hasDangerousBody = false;
|
|
2412
|
+
const cbSource = callback.start !== undefined && callback.end !== undefined
|
|
2413
|
+
? ctx._sourceCode?.slice(callback.start, callback.end) : null;
|
|
2414
|
+
if (cbSource && /\b(child_process|exec|execSync|spawn|spawnSync)\b/.test(cbSource)) {
|
|
2415
|
+
hasDangerousBody = true;
|
|
2416
|
+
}
|
|
2417
|
+
// Also flag if the callback is a variable known to be dangerous
|
|
2418
|
+
if (callback.type === 'Identifier' && ctx.evalAliases?.has(callback.name)) {
|
|
2419
|
+
hasDangerousBody = true;
|
|
2420
|
+
}
|
|
2421
|
+
if (hasDangerousBody) {
|
|
2422
|
+
ctx.hasDynamicExec = true;
|
|
2423
|
+
ctx.threats.push({
|
|
2424
|
+
type: 'finalization_registry_exec',
|
|
2425
|
+
severity: 'CRITICAL',
|
|
2426
|
+
message: 'new FinalizationRegistry() with dangerous callback — deferred code execution triggered by garbage collection, evades synchronous analysis.',
|
|
2427
|
+
file: ctx.relFile
|
|
2428
|
+
});
|
|
2429
|
+
} else {
|
|
2430
|
+
ctx.hasFinalizationRegistry = true;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2337
2434
|
}
|
|
2338
2435
|
|
|
2339
2436
|
function handleLiteral(node, ctx) {
|
|
@@ -2463,6 +2560,55 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
2463
2560
|
}
|
|
2464
2561
|
}
|
|
2465
2562
|
|
|
2563
|
+
// B6: Symbol property hiding — obj[Symbol(...)] = require('child_process')
|
|
2564
|
+
if (node.left?.type === 'MemberExpression' && node.left.computed &&
|
|
2565
|
+
node.left.property?.type === 'CallExpression' &&
|
|
2566
|
+
node.left.property.callee?.type === 'Identifier' && node.left.property.callee.name === 'Symbol') {
|
|
2567
|
+
// Check if the right side is require('child_process') or similar dangerous module
|
|
2568
|
+
let isDangerous = false;
|
|
2569
|
+
let modName = null;
|
|
2570
|
+
if (node.right?.type === 'CallExpression' && getCallName(node.right) === 'require' &&
|
|
2571
|
+
node.right.arguments?.[0]?.type === 'Literal') {
|
|
2572
|
+
const rawMod = node.right.arguments[0].value;
|
|
2573
|
+
modName = typeof rawMod === 'string' && rawMod.startsWith('node:') ? rawMod.slice(5) : rawMod;
|
|
2574
|
+
if (['child_process', 'fs', 'net', 'dns', 'http', 'https'].includes(modName)) {
|
|
2575
|
+
isDangerous = true;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
// Also detect: obj[Symbol('x')] = eval / Function / exec
|
|
2579
|
+
if (node.right?.type === 'Identifier' && ['eval', 'Function'].includes(node.right.name)) {
|
|
2580
|
+
isDangerous = true;
|
|
2581
|
+
}
|
|
2582
|
+
if (isDangerous) {
|
|
2583
|
+
ctx.threats.push({
|
|
2584
|
+
type: 'symbol_property_hiding',
|
|
2585
|
+
severity: 'HIGH',
|
|
2586
|
+
message: `Dangerous module/function hidden behind Symbol property — obj[Symbol(...)] = ${modName ? "require('" + modName + "')" : node.right?.name || '...'}, evades string-based property enumeration.`,
|
|
2587
|
+
file: ctx.relFile
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// B5: Module.wrap = ... or require('module').wrap = ... — module wrapper override
|
|
2593
|
+
if (node.left?.type === 'MemberExpression' &&
|
|
2594
|
+
node.left.property?.type === 'Identifier' && node.left.property.name === 'wrap') {
|
|
2595
|
+
const obj = node.left.object;
|
|
2596
|
+
// Direct: Module.wrap = ... (where Module was imported via require('module'))
|
|
2597
|
+
const isModuleObj = (obj?.type === 'Identifier' && ctx.moduleAliases?.has(obj.name)) ||
|
|
2598
|
+
(obj?.type === 'Identifier' && obj.name === 'Module');
|
|
2599
|
+
// Inline: require('module').wrap = ...
|
|
2600
|
+
const isInlineRequire = obj?.type === 'CallExpression' && getCallName(obj) === 'require' &&
|
|
2601
|
+
obj.arguments?.[0]?.type === 'Literal' && obj.arguments[0].value === 'module';
|
|
2602
|
+
if (isModuleObj || isInlineRequire) {
|
|
2603
|
+
ctx.threats.push({
|
|
2604
|
+
type: 'module_wrap_override',
|
|
2605
|
+
severity: 'CRITICAL',
|
|
2606
|
+
message: 'Module.wrap overridden — module wrapper function hijacked, allows injecting code into every loaded module.',
|
|
2607
|
+
file: ctx.relFile
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2466
2612
|
// Detect object property indirection: obj.exec = require('child_process').exec
|
|
2467
2613
|
// or obj.fn = eval — stashing dangerous functions in object properties
|
|
2468
2614
|
if (node.left?.type === 'MemberExpression' && node.right) {
|
|
@@ -2517,6 +2663,17 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
2517
2663
|
}
|
|
2518
2664
|
}
|
|
2519
2665
|
|
|
2666
|
+
// B4: Prototype pollution — __proto__ assignment
|
|
2667
|
+
if (node.left?.type === 'MemberExpression' && !node.left.computed &&
|
|
2668
|
+
node.left.property?.type === 'Identifier' && node.left.property.name === '__proto__') {
|
|
2669
|
+
ctx.threats.push({
|
|
2670
|
+
type: 'prototype_pollution',
|
|
2671
|
+
severity: 'HIGH',
|
|
2672
|
+
message: `__proto__ assignment on ${node.left.object?.name || 'object'} — prototype pollution can hijack inherited properties across all objects.`,
|
|
2673
|
+
file: ctx.relFile
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2520
2677
|
if (node.left?.type === 'MemberExpression') {
|
|
2521
2678
|
const left = node.left;
|
|
2522
2679
|
|
|
@@ -3021,6 +3178,16 @@ function handlePostWalk(ctx) {
|
|
|
3021
3178
|
file: ctx.relFile
|
|
3022
3179
|
});
|
|
3023
3180
|
}
|
|
3181
|
+
|
|
3182
|
+
// B2 compound: FinalizationRegistry + exec/network in same file = deferred malicious execution
|
|
3183
|
+
if (ctx.hasFinalizationRegistry && ctx.hasDynamicExec) {
|
|
3184
|
+
ctx.threats.push({
|
|
3185
|
+
type: 'finalization_registry_exec',
|
|
3186
|
+
severity: 'CRITICAL',
|
|
3187
|
+
message: 'FinalizationRegistry + dynamic execution in same file — deferred code execution triggered by garbage collection.',
|
|
3188
|
+
file: ctx.relFile
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3024
3191
|
}
|
|
3025
3192
|
|
|
3026
3193
|
function handleWithStatement(node, ctx) {
|
|
@@ -3049,6 +3216,22 @@ function handleWithStatement(node, ctx) {
|
|
|
3049
3216
|
file: ctx.relFile
|
|
3050
3217
|
});
|
|
3051
3218
|
}
|
|
3219
|
+
return; // Already handled as direct with(require(...))
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
// B7: with(obj) { ... require('child_process') ... } — body contains dangerous require/exec
|
|
3223
|
+
// The with statement itself is rare in modern code; combined with dangerous APIs in body = evasion
|
|
3224
|
+
if (node.body) {
|
|
3225
|
+
const bodySource = node.body.start !== undefined && node.body.end !== undefined
|
|
3226
|
+
? ctx._sourceCode?.slice(node.body.start, node.body.end) : null;
|
|
3227
|
+
if (bodySource && /\b(require\s*\(\s*['"]child_process['"]\s*\)|child_process|exec\s*\(|execSync\s*\(|spawn\s*\()/.test(bodySource)) {
|
|
3228
|
+
ctx.threats.push({
|
|
3229
|
+
type: 'with_body_dangerous',
|
|
3230
|
+
severity: 'HIGH',
|
|
3231
|
+
message: 'with() statement body contains require/exec/spawn — scope injection used to obscure dangerous API calls.',
|
|
3232
|
+
file: ctx.relFile
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3052
3235
|
}
|
|
3053
3236
|
}
|
|
3054
3237
|
|
package/src/scanner/ast.js
CHANGED
|
@@ -180,7 +180,11 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
180
180
|
hasSolanaImport: false,
|
|
181
181
|
hasSolanaC2Method: false,
|
|
182
182
|
// Audit v3: uncaughtException/unhandledRejection handler for error hijacking detection
|
|
183
|
-
hasUncaughtExceptionHandler: false
|
|
183
|
+
hasUncaughtExceptionHandler: false,
|
|
184
|
+
// Audit v3 B2: FinalizationRegistry deferred exec detection
|
|
185
|
+
hasFinalizationRegistry: false,
|
|
186
|
+
// Audit v3: source code reference for callback body analysis
|
|
187
|
+
_sourceCode: content
|
|
184
188
|
};
|
|
185
189
|
|
|
186
190
|
// Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
|
package/src/scanner/shell.js
CHANGED
|
@@ -6,15 +6,15 @@ const { MAX_FILE_SIZE, getMaxFileSize } = require('../shared/constants.js');
|
|
|
6
6
|
const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
|
|
7
7
|
|
|
8
8
|
const MALICIOUS_PATTERNS = [
|
|
9
|
-
{ pattern: /curl
|
|
10
|
-
{ pattern: /wget
|
|
9
|
+
{ pattern: /curl[^\n]{0,5000}\|[^\n]{0,5000}sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
|
|
10
|
+
{ pattern: /wget[^\n]{0,5000}&&[^\n]{0,5000}chmod[^\n]{0,5000}\+x/m, name: 'wget_chmod_exec', severity: 'HIGH' },
|
|
11
11
|
{ pattern: /bash\s+-i\s+>&\s+\/dev\/tcp/m, name: 'reverse_shell', severity: 'CRITICAL' },
|
|
12
12
|
{ pattern: /nc\s+-e\s+\/bin\/(ba)?sh/m, name: 'netcat_shell', severity: 'CRITICAL' },
|
|
13
13
|
{ pattern: /rm\s+-rf\s+(~\/|\$HOME|\/home)/m, name: 'home_deletion', severity: 'CRITICAL' },
|
|
14
14
|
{ pattern: /shred.*\$HOME/m, name: 'shred_home', severity: 'CRITICAL' },
|
|
15
|
-
{ pattern: /curl
|
|
16
|
-
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s
|
|
17
|
-
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s
|
|
15
|
+
{ pattern: /curl[^\n]{0,5000}-X\s*POST[^\n]{0,5000}-d/m, name: 'curl_exfiltration', severity: 'HIGH' },
|
|
16
|
+
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+[^\n]{0,5000}|scp\s+)[^\n]{0,5000}\.npmrc/m, name: 'npmrc_access', severity: 'HIGH' },
|
|
17
|
+
{ pattern: /(?:cat|readFile|cp|mv|curl\s+file:\/\/|tar\s+[^\n]{0,5000}|scp\s+)[^\n]{0,5000}\.ssh/m, name: 'ssh_access', severity: 'HIGH' },
|
|
18
18
|
{ pattern: /python\s+-c.*import\s+socket/m, name: 'python_reverse_shell', severity: 'CRITICAL' },
|
|
19
19
|
{ pattern: /perl\s+-e.*socket/m, name: 'perl_reverse_shell', severity: 'CRITICAL' },
|
|
20
20
|
{ pattern: /mkfifo.*\/dev\/tcp/m, name: 'fifo_reverse_shell', severity: 'CRITICAL' },
|
package/src/scoring.js
CHANGED
|
@@ -102,8 +102,8 @@ const PACKAGE_LEVEL_TYPES = new Set([
|
|
|
102
102
|
'shai_hulud_marker', 'suspicious_file',
|
|
103
103
|
'pypi_malicious_package', 'pypi_typosquat_detected',
|
|
104
104
|
'dangerous_api_added_critical', 'dangerous_api_added_high', 'dangerous_api_added_medium',
|
|
105
|
-
'publish_burst', '
|
|
106
|
-
'
|
|
105
|
+
'publish_burst', 'dormant_spike', 'rapid_succession',
|
|
106
|
+
'suspicious_maintainer', 'sole_maintainer_change',
|
|
107
107
|
'sandbox_network_activity', 'sandbox_file_changes', 'sandbox_process_spawns',
|
|
108
108
|
'sandbox_canary_exfiltration',
|
|
109
109
|
// Compound scoring rules — package-level co-occurrences
|
package/src/shared/constants.js
CHANGED
|
@@ -99,13 +99,13 @@ function resetMaxFileSize() { _maxFileSize = MAX_FILE_SIZE; }
|
|
|
99
99
|
const ACORN_OPTIONS = { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true };
|
|
100
100
|
|
|
101
101
|
const acorn = require('acorn');
|
|
102
|
+
const crypto = require('crypto');
|
|
102
103
|
|
|
103
104
|
/**
|
|
104
105
|
* AST parse cache — same content+options returns the same AST.
|
|
105
106
|
* Scanners do not mutate AST nodes (verified: only read comparisons).
|
|
106
107
|
* Cleared between scans via clearASTCache().
|
|
107
|
-
* Key = code
|
|
108
|
-
* (length-prefixed partial key for fast Map lookup; collisions resolved by full WeakRef check)
|
|
108
|
+
* Key = sha256(code) + '|' + optionsKey (collision-free content-addressable key)
|
|
109
109
|
*/
|
|
110
110
|
const _astCache = new Map();
|
|
111
111
|
const _AST_CACHE_MAX = 600; // Max entries (one scan ≈ 500 files max)
|
|
@@ -117,9 +117,9 @@ const _AST_CACHE_MAX = 600; // Max entries (one scan ≈ 500 files max)
|
|
|
117
117
|
* Returns AST or null if both modes fail.
|
|
118
118
|
*/
|
|
119
119
|
function safeParse(code, extraOptions = {}) {
|
|
120
|
-
// Build cache key:
|
|
120
|
+
// Build cache key: sha256 content hash + options signature
|
|
121
121
|
const optKey = Object.keys(extraOptions).length === 0 ? '' : JSON.stringify(extraOptions);
|
|
122
|
-
const cacheKey = code.
|
|
122
|
+
const cacheKey = crypto.createHash('sha256').update(code).digest('hex') + '|' + optKey;
|
|
123
123
|
|
|
124
124
|
const cached = _astCache.get(cacheKey);
|
|
125
125
|
if (cached !== undefined) return cached;
|
package/src/utils.js
CHANGED
|
@@ -23,6 +23,7 @@ let _scanRoot = '';
|
|
|
23
23
|
*/
|
|
24
24
|
const _fileListCache = new Map();
|
|
25
25
|
let _filesCapped = false;
|
|
26
|
+
let _overflowFiles = [];
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* File content cache — read each file once, reused across all scanners in a single scan.
|
|
@@ -121,6 +122,7 @@ function findFiles(dir, options = {}) {
|
|
|
121
122
|
return depthA - depthB;
|
|
122
123
|
});
|
|
123
124
|
const capped = result.slice(0, maxFiles);
|
|
125
|
+
_overflowFiles = result.slice(maxFiles);
|
|
124
126
|
_fileListCache.set(cacheKey, [...capped]);
|
|
125
127
|
_filesCapped = true;
|
|
126
128
|
return capped;
|
|
@@ -227,12 +229,17 @@ function clearFileListCache() {
|
|
|
227
229
|
_fileContentCache.clear();
|
|
228
230
|
clearASTCache();
|
|
229
231
|
_filesCapped = false;
|
|
232
|
+
_overflowFiles = [];
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
function wasFilesCapped() {
|
|
233
236
|
return _filesCapped;
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
function getOverflowFiles() {
|
|
240
|
+
return _overflowFiles;
|
|
241
|
+
}
|
|
242
|
+
|
|
236
243
|
/**
|
|
237
244
|
* Escapes HTML characters to prevent XSS
|
|
238
245
|
* @param {string} str - String to escape
|
|
@@ -394,6 +401,7 @@ module.exports = {
|
|
|
394
401
|
findJsFiles,
|
|
395
402
|
clearFileListCache,
|
|
396
403
|
wasFilesCapped,
|
|
404
|
+
getOverflowFiles,
|
|
397
405
|
escapeHtml,
|
|
398
406
|
getCallName,
|
|
399
407
|
Spinner,
|