muaddib-scanner 2.9.2 → 2.9.3
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/package.json +1 -1
- package/src/index.js +4 -0
- package/src/response/playbooks.js +0 -9
- package/src/rules/index.js +0 -24
- package/src/scanner/ast-detectors.js +7 -3
- package/src/scanner/obfuscation.js +8 -6
- package/src/scanner/package.js +5 -0
- package/src/scoring.js +36 -20
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -570,6 +570,7 @@ async function run(targetPath, options = {}) {
|
|
|
570
570
|
// Cross-scanner compound: detached_process + suspicious_dataflow in same file
|
|
571
571
|
// Catches cases where credential flow is detected by dataflow scanner, not AST scanner
|
|
572
572
|
{
|
|
573
|
+
const DIST_RE = /(?:^|[/\\])(?:dist|build|out|output)[/\\]|\.min\.js$|\.bundle\.js$/i;
|
|
573
574
|
const fileMap = Object.create(null);
|
|
574
575
|
for (const t of deduped) {
|
|
575
576
|
if (t.file) {
|
|
@@ -578,6 +579,9 @@ async function run(targetPath, options = {}) {
|
|
|
578
579
|
}
|
|
579
580
|
}
|
|
580
581
|
for (const file of Object.keys(fileMap)) {
|
|
582
|
+
// Skip dist/build files — bundler aggregation creates coincidental co-occurrence
|
|
583
|
+
// of detached_process + suspicious_dataflow. Real DPRK attacks target root files.
|
|
584
|
+
if (DIST_RE.test(file)) continue;
|
|
581
585
|
const fileThreats = fileMap[file];
|
|
582
586
|
const hasDetached = fileThreats.some(t => t.type === 'detached_process');
|
|
583
587
|
const hasCredFlow = fileThreats.some(t => t.type === 'suspicious_dataflow');
|
|
@@ -547,11 +547,6 @@ const PLAYBOOKS = {
|
|
|
547
547
|
'Vecteur classique de dependency confusion: le code s\'execute a l\'installation. ' +
|
|
548
548
|
'NE PAS installer. Verifier le nom exact du package. Signaler sur npm.',
|
|
549
549
|
|
|
550
|
-
credential_env_exfil:
|
|
551
|
-
'CRITIQUE: Ecriture dans des chemins sensibles (cache npm/yarn, credentials) + acces aux variables d\'environnement. ' +
|
|
552
|
-
'Double vecteur d\'exfiltration de credentials. Supprimer le package. Regenerer tous les secrets. ' +
|
|
553
|
-
'Nettoyer le cache: npm cache clean --force.',
|
|
554
|
-
|
|
555
550
|
lifecycle_inline_exec:
|
|
556
551
|
'CRITIQUE: Script lifecycle avec node -e (execution inline). Le code s\'execute automatiquement a npm install. ' +
|
|
557
552
|
'NE PAS installer. Si deja installe: considerer la machine compromise. ' +
|
|
@@ -562,10 +557,6 @@ const PLAYBOOKS = {
|
|
|
562
557
|
'Le payload est telecharge et execute automatiquement a l\'installation. ' +
|
|
563
558
|
'NE PAS installer. Bloquer les connexions sortantes. Supprimer le package.',
|
|
564
559
|
|
|
565
|
-
obfuscated_credential_tampering:
|
|
566
|
-
'CRITIQUE: Code obfusque + ecriture dans des chemins sensibles. Dissimulation de vol de credentials. ' +
|
|
567
|
-
'Supprimer le package immediatement. Nettoyer le cache npm/yarn. Regenerer tous les secrets.',
|
|
568
|
-
|
|
569
560
|
bin_field_hijack:
|
|
570
561
|
'CRITIQUE: Le champ "bin" de package.json shadow une commande systeme (node, npm, git, bash, etc.). ' +
|
|
571
562
|
'A l\'installation, npm cree un symlink dans node_modules/.bin/ qui intercepte la commande reelle. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -1621,18 +1621,6 @@ const RULES = {
|
|
|
1621
1621
|
],
|
|
1622
1622
|
mitre: 'T1195.002'
|
|
1623
1623
|
},
|
|
1624
|
-
credential_env_exfil: {
|
|
1625
|
-
id: 'MUADDIB-COMPOUND-003',
|
|
1626
|
-
name: 'Credential Tampering + Env Access',
|
|
1627
|
-
severity: 'CRITICAL',
|
|
1628
|
-
confidence: 'high',
|
|
1629
|
-
description: 'Ecriture dans un chemin sensible (cache npm/yarn, credentials) combinee avec acces aux variables d\'environnement. Chaine d\'exfiltration de credentials par double vecteur.',
|
|
1630
|
-
references: [
|
|
1631
|
-
'https://attack.mitre.org/techniques/T1552/001/',
|
|
1632
|
-
'https://attack.mitre.org/techniques/T1565/001/'
|
|
1633
|
-
],
|
|
1634
|
-
mitre: 'T1552.001'
|
|
1635
|
-
},
|
|
1636
1624
|
lifecycle_inline_exec: {
|
|
1637
1625
|
id: 'MUADDIB-COMPOUND-004',
|
|
1638
1626
|
name: 'Lifecycle Hook + Inline Node Execution',
|
|
@@ -1657,18 +1645,6 @@ const RULES = {
|
|
|
1657
1645
|
],
|
|
1658
1646
|
mitre: 'T1105'
|
|
1659
1647
|
},
|
|
1660
|
-
obfuscated_credential_tampering: {
|
|
1661
|
-
id: 'MUADDIB-COMPOUND-006',
|
|
1662
|
-
name: 'Obfuscated Code + Credential Tampering',
|
|
1663
|
-
severity: 'CRITICAL',
|
|
1664
|
-
confidence: 'high',
|
|
1665
|
-
description: 'Code obfusque combine avec ecriture dans des chemins sensibles (cache npm/yarn, credentials). Dissimulation de vol de credentials.',
|
|
1666
|
-
references: [
|
|
1667
|
-
'https://attack.mitre.org/techniques/T1027/',
|
|
1668
|
-
'https://attack.mitre.org/techniques/T1565/001/'
|
|
1669
|
-
],
|
|
1670
|
-
mitre: 'T1027'
|
|
1671
|
-
},
|
|
1672
1648
|
};
|
|
1673
1649
|
|
|
1674
1650
|
function getRule(type) {
|
|
@@ -2360,7 +2360,7 @@ function handlePostWalk(ctx) {
|
|
|
2360
2360
|
t.file === ctx.relFile && t.type === 'detached_process'
|
|
2361
2361
|
);
|
|
2362
2362
|
const hasSensitiveEnvInFile = ctx.threats.some(t =>
|
|
2363
|
-
t.file === ctx.relFile && t.type === 'env_access'
|
|
2363
|
+
t.file === ctx.relFile && t.type === 'env_access' && t.severity === 'HIGH'
|
|
2364
2364
|
);
|
|
2365
2365
|
if (hasDetachedInFile && hasSensitiveEnvInFile && ctx.hasNetworkCallInFile) {
|
|
2366
2366
|
ctx.threats.push({
|
|
@@ -2372,11 +2372,15 @@ function handlePostWalk(ctx) {
|
|
|
2372
2372
|
}
|
|
2373
2373
|
|
|
2374
2374
|
// GlassWorm: Unicode variation selector decoder = .codePointAt + variation selector constants
|
|
2375
|
+
// CRITICAL if combined with eval/exec (GlassWorm always uses dynamic execution),
|
|
2376
|
+
// MEDIUM otherwise (.codePointAt + 0xFE00 is legitimate Unicode processing in fonts/text libs)
|
|
2375
2377
|
if (ctx.hasCodePointAt && ctx.hasVariationSelectorConst) {
|
|
2376
2378
|
ctx.threats.push({
|
|
2377
2379
|
type: 'unicode_variation_decoder',
|
|
2378
|
-
severity: 'CRITICAL',
|
|
2379
|
-
message:
|
|
2380
|
+
severity: ctx.hasDynamicExec ? 'CRITICAL' : 'MEDIUM',
|
|
2381
|
+
message: ctx.hasDynamicExec
|
|
2382
|
+
? 'Unicode variation selector decoder: .codePointAt() + 0xFE00/0xE0100 constants + dynamic execution — GlassWorm payload reconstruction from invisible characters.'
|
|
2383
|
+
: 'Unicode variation selector decoder: .codePointAt() + 0xFE00/0xE0100 constants — likely legitimate Unicode processing (text formatting, font rendering).',
|
|
2380
2384
|
file: ctx.relFile
|
|
2381
2385
|
});
|
|
2382
2386
|
}
|
|
@@ -23,7 +23,9 @@ function detectObfuscation(targetPath) {
|
|
|
23
23
|
// P6: Any JS file > 100KB is overwhelmingly bundled output regardless of directory name.
|
|
24
24
|
// Real obfuscated malware is typically small (<50KB). Catches prettier plugins/, svelte compiler/, etc.
|
|
25
25
|
const isLargeJs = basename.endsWith('.js') && content.length > 100 * 1024;
|
|
26
|
-
|
|
26
|
+
// Locale/i18n files legitimately contain invisible Unicode (e.g. Persian ZWNJ U+200C)
|
|
27
|
+
const isLocaleFile = /(?:^|[/\\])(?:locale|locales|i18n|intl|lang|languages|translations)[/\\]/i.test(relativePath);
|
|
28
|
+
const isPackageOutput = isMinified || isBundled || isInDistOrBuild || isLargeCjsMjs || isLargeJs || isLocaleFile;
|
|
27
29
|
|
|
28
30
|
// 1. Ratio code sur une seule ligne (skip .min.js — minification, not obfuscation)
|
|
29
31
|
if (!isMinified) {
|
|
@@ -73,11 +75,11 @@ function detectObfuscation(targetPath) {
|
|
|
73
75
|
// 7. Unicode invisible character injection (GlassWorm — mars 2026)
|
|
74
76
|
// Detects zero-width chars, variation selectors, tag characters embedded in source
|
|
75
77
|
const invisibleCount = countInvisibleUnicode(content);
|
|
76
|
-
if (invisibleCount >=
|
|
78
|
+
if (invisibleCount >= 10) {
|
|
77
79
|
threats.push({
|
|
78
80
|
type: 'unicode_invisible_injection',
|
|
79
81
|
severity: isPackageOutput ? 'LOW' : 'CRITICAL',
|
|
80
|
-
message: `${invisibleCount} invisible Unicode characters detected (zero-width, variation selectors, tag chars).
|
|
82
|
+
message: `${invisibleCount} invisible Unicode characters detected (zero-width, variation selectors, tag chars). Possible hidden payload encoded via invisible codepoints.`,
|
|
81
83
|
file: relativePath
|
|
82
84
|
});
|
|
83
85
|
}
|
|
@@ -151,7 +153,7 @@ function hasLargeStringArray(content) {
|
|
|
151
153
|
* - U+200B, U+200C, U+200D (zero-width space/joiner/non-joiner)
|
|
152
154
|
* - U+FEFF (BOM — only if position > 0; pos 0 is legitimate BOM)
|
|
153
155
|
* - U+2060 (word joiner), U+180E (Mongolian vowel separator)
|
|
154
|
-
* - U+FE00-U+
|
|
156
|
+
* - U+FE00-U+FE0E (variation selectors — excludes U+FE0F emoji presentation selector)
|
|
155
157
|
* - U+E0100-U+E01EF (variation selectors supplement)
|
|
156
158
|
* - U+E0001-U+E007F (tag characters)
|
|
157
159
|
*/
|
|
@@ -168,8 +170,8 @@ function countInvisibleUnicode(content) {
|
|
|
168
170
|
else if (cp === 0xFEFF && i > 0) {
|
|
169
171
|
count++;
|
|
170
172
|
}
|
|
171
|
-
// BMP variation selectors (U+FE00-U+FE0F)
|
|
172
|
-
else if (cp >= 0xFE00 && cp <=
|
|
173
|
+
// BMP variation selectors (U+FE00-U+FE0E) — excludes U+FE0F (emoji presentation selector)
|
|
174
|
+
else if (cp >= 0xFE00 && cp <= 0xFE0E) {
|
|
173
175
|
count++;
|
|
174
176
|
}
|
|
175
177
|
// Supplementary plane: variation selectors supplement (U+E0100-U+E01EF)
|
package/src/scanner/package.js
CHANGED
|
@@ -137,6 +137,11 @@ async function scanPackageJson(targetPath) {
|
|
|
137
137
|
: pkg.bin;
|
|
138
138
|
for (const [cmdName, cmdPath] of Object.entries(binEntries || {})) {
|
|
139
139
|
if (SHADOWED_COMMANDS.has(cmdName)) {
|
|
140
|
+
// Skip when the package IS the legitimate provider of the command:
|
|
141
|
+
// 1. Self-name: npm→bin.npm, yarn→bin.yarn
|
|
142
|
+
// 2. Sibling commands: npm also provides npx → pkg.name in SHADOWED_COMMANDS
|
|
143
|
+
// Typosquats still caught: 'nmp' declaring bin.npm → 'nmp' not in SHADOWED_COMMANDS → fires
|
|
144
|
+
if (cmdName === pkg.name || SHADOWED_COMMANDS.has(pkg.name)) continue;
|
|
140
145
|
threats.push({
|
|
141
146
|
type: 'bin_field_hijack',
|
|
142
147
|
severity: 'CRITICAL',
|
package/src/scoring.js
CHANGED
|
@@ -156,14 +156,16 @@ const DIST_EXEMPT_TYPES = new Set([
|
|
|
156
156
|
'cross_file_dataflow', // credential read → network exfil across files
|
|
157
157
|
'staged_eval_decode', // eval(atob(...)) (explicit payload staging)
|
|
158
158
|
'reverse_shell', // net.Socket + connect + pipe (always malicious)
|
|
159
|
-
|
|
159
|
+
// detached_credential_exfil removed from DIST_EXEMPT: in dist/ files, co-occurrence of
|
|
160
|
+
// detached_process + env_access + network is coincidental bundler aggregation.
|
|
161
|
+
// Kept in REACHABILITY_EXEMPT_TYPES (lifecycle invocation is valid).
|
|
160
162
|
'node_modules_write', // writeFile to node_modules/ (worm propagation)
|
|
161
163
|
'npm_publish_worm', // exec("npm publish") (worm propagation)
|
|
162
164
|
// Dangerous shell commands in dist/ are real threats, never bundler output
|
|
163
165
|
'dangerous_exec',
|
|
164
166
|
// Compound scoring rules — co-occurrence signals, never FP
|
|
165
|
-
'crypto_staged_payload', 'lifecycle_typosquat',
|
|
166
|
-
'lifecycle_inline_exec', 'lifecycle_remote_require'
|
|
167
|
+
'crypto_staged_payload', 'lifecycle_typosquat',
|
|
168
|
+
'lifecycle_inline_exec', 'lifecycle_remote_require'
|
|
167
169
|
// P6: remote_code_load and proxy_data_intercept removed — in bundled dist/ files,
|
|
168
170
|
// fetch + eval co-occurrence is coincidental (bundler combines HTTP client + template compilation).
|
|
169
171
|
// fetch_decrypt_exec (fetch+decrypt+eval triple) remains exempt — never coincidental.
|
|
@@ -181,7 +183,7 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
|
|
|
181
183
|
'dynamic_require', 'dynamic_import',
|
|
182
184
|
'obfuscation_detected', 'high_entropy_string', 'possible_obfuscation',
|
|
183
185
|
'js_obfuscation_pattern', 'vm_code_execution',
|
|
184
|
-
'module_compile', 'module_compile_dynamic',
|
|
186
|
+
'module_compile', 'module_compile_dynamic', 'unicode_variation_decoder',
|
|
185
187
|
// P7: env_access in dist/ is bundled SDK config reading, not credential theft
|
|
186
188
|
'env_access',
|
|
187
189
|
// P8: Proxy traps in dist/ are state management frameworks (MobX, Vue reactivity, Immer),
|
|
@@ -189,7 +191,12 @@ const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
|
|
|
189
191
|
'proxy_data_intercept',
|
|
190
192
|
// P9: fetch+eval in dist/ is Vite/Webpack code splitting (lazy chunk loading),
|
|
191
193
|
// not remote code execution. Two-notch downgrade (CRITICAL→MEDIUM, HIGH→LOW).
|
|
192
|
-
'remote_code_load'
|
|
194
|
+
'remote_code_load',
|
|
195
|
+
// P10: In dist/ bundles, binary file refs + crypto are coincidental bundler aggregation
|
|
196
|
+
// (webpack bundles crypto utils alongside image processing). Real steganographic attacks
|
|
197
|
+
// (flatmap-stream) have these at package root, not dist/. Compound (crypto_staged_payload)
|
|
198
|
+
// is in DIST_EXEMPT_TYPES so the overall signal is preserved when truly malicious.
|
|
199
|
+
'staged_binary_payload', 'crypto_decipher'
|
|
193
200
|
]);
|
|
194
201
|
|
|
195
202
|
// Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.
|
|
@@ -222,7 +229,8 @@ const SCORING_COMPOUNDS = [
|
|
|
222
229
|
requires: ['staged_binary_payload', 'crypto_decipher'],
|
|
223
230
|
severity: 'CRITICAL',
|
|
224
231
|
message: 'Binary file reference + crypto decryption — steganographic payload chain (scoring compound).',
|
|
225
|
-
fileFrom: 'staged_binary_payload'
|
|
232
|
+
fileFrom: 'staged_binary_payload',
|
|
233
|
+
sameFile: true // Real steganographic attacks (flatmap-stream) have crypto+binary in the SAME file
|
|
226
234
|
},
|
|
227
235
|
{
|
|
228
236
|
type: 'lifecycle_typosquat',
|
|
@@ -231,13 +239,6 @@ const SCORING_COMPOUNDS = [
|
|
|
231
239
|
message: 'Lifecycle hook on typosquat package — dependency confusion attack vector (scoring compound).',
|
|
232
240
|
fileFrom: 'typosquat_detected'
|
|
233
241
|
},
|
|
234
|
-
{
|
|
235
|
-
type: 'credential_env_exfil',
|
|
236
|
-
requires: ['credential_tampering', 'env_access'],
|
|
237
|
-
severity: 'CRITICAL',
|
|
238
|
-
message: 'Credential path tampering + environment variable access — credential exfiltration chain (scoring compound).',
|
|
239
|
-
fileFrom: 'credential_tampering'
|
|
240
|
-
},
|
|
241
242
|
{
|
|
242
243
|
type: 'lifecycle_inline_exec',
|
|
243
244
|
requires: ['lifecycle_script', 'node_inline_exec'],
|
|
@@ -252,13 +253,6 @@ const SCORING_COMPOUNDS = [
|
|
|
252
253
|
message: 'Lifecycle hook loading remote code (require http/https) — supply chain payload delivery (scoring compound).',
|
|
253
254
|
fileFrom: 'network_require'
|
|
254
255
|
},
|
|
255
|
-
{
|
|
256
|
-
type: 'obfuscated_credential_tampering',
|
|
257
|
-
requires: ['credential_tampering', 'obfuscation_detected'],
|
|
258
|
-
severity: 'CRITICAL',
|
|
259
|
-
message: 'Obfuscated code + credential path tampering — concealed credential theft (scoring compound).',
|
|
260
|
-
fileFrom: 'credential_tampering'
|
|
261
|
-
}
|
|
262
256
|
];
|
|
263
257
|
|
|
264
258
|
/**
|
|
@@ -284,6 +278,28 @@ function applyCompoundBoosts(threats) {
|
|
|
284
278
|
|
|
285
279
|
// Check all required types are present
|
|
286
280
|
if (compound.requires.every(req => typeSet.has(req))) {
|
|
281
|
+
// Severity gate: at least one component must have severity >= MEDIUM
|
|
282
|
+
// after FP reductions. If all components were downgraded to LOW,
|
|
283
|
+
// the compound signal is not strong enough to justify a CRITICAL boost.
|
|
284
|
+
const hasSignificantComponent = compound.requires.some(req =>
|
|
285
|
+
threats.some(t => t.type === req && t.severity !== 'LOW')
|
|
286
|
+
);
|
|
287
|
+
if (!hasSignificantComponent) continue;
|
|
288
|
+
|
|
289
|
+
// Same-file constraint: all required types must appear in at least one common file.
|
|
290
|
+
// Prevents cross-file coincidental matches (e.g. next.js: staged_binary_payload in
|
|
291
|
+
// dist/compiled/@vercel/nft/index.js + crypto_decipher in a different file).
|
|
292
|
+
if (compound.sameFile) {
|
|
293
|
+
const filesByType = compound.requires.map(req =>
|
|
294
|
+
new Set(threats.filter(t => t.type === req).map(t => t.file))
|
|
295
|
+
);
|
|
296
|
+
// Find intersection of all file sets
|
|
297
|
+
const commonFiles = [...filesByType[0]].filter(f =>
|
|
298
|
+
filesByType.every(s => s.has(f))
|
|
299
|
+
);
|
|
300
|
+
if (commonFiles.length === 0) continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
287
303
|
threats.push({
|
|
288
304
|
type: compound.type,
|
|
289
305
|
severity: compound.severity,
|