muaddib-scanner 2.11.156 → 2.11.158
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/{self-scan-v2.11.156.json → self-scan-v2.11.158.json} +1 -1
- package/src/monitor/classify.js +5 -1
- package/src/monitor/webhook.js +21 -1
- package/src/response/playbooks.js +9 -0
- package/src/rules/index.js +14 -0
- package/src/scanner/ast-detectors/electron-injection.js +233 -0
- package/src/scanner/ast-detectors/handle-assignment-expression.js +8 -0
- package/src/scanner/ast-detectors/handle-call-expression.js +32 -0
- package/src/scanner/ast-detectors/handle-post-walk.js +33 -0
- package/src/scanner/ast-detectors/handle-variable-declarator.js +19 -0
- package/src/scanner/ast.js +10 -0
package/package.json
CHANGED
package/src/monitor/classify.js
CHANGED
|
@@ -78,7 +78,11 @@ const HIGH_CONFIDENCE_MALICE_TYPES = new Set([
|
|
|
78
78
|
// Phantom Gyp 2026-06: binding.gyp command-substitution = install-time RCE, quasi-never legit in benign packages
|
|
79
79
|
'gyp_command_exec',
|
|
80
80
|
// Phantom Gyp compound (Phase 1b): configure-time <!(node x.js) × independently-malicious invoked file
|
|
81
|
-
'gyp_phantom_exec'
|
|
81
|
+
'gyp_phantom_exec',
|
|
82
|
+
// electron_app_injection (MUADDIB-AST-097): install hook overwrites a THIRD-PARTY Electron app's
|
|
83
|
+
// core/.asar with injected code (payload-in-write coupling ⇒ FP≈0). Bypasses MT-1 cap like the
|
|
84
|
+
// other install-time RCE/injection markers above (node_modules_write, systemd_persistence).
|
|
85
|
+
'electron_app_injection'
|
|
82
86
|
]);
|
|
83
87
|
|
|
84
88
|
// Lifecycle compound types that indicate real malicious intent beyond a simple postinstall
|
package/src/monitor/webhook.js
CHANGED
|
@@ -270,11 +270,30 @@ function buildBurstPreAlertEmbed(name, count, ecosystem = 'npm') {
|
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Burst pre-alert Discord toggle — OFF by default. Measured 2026-07: the burst
|
|
275
|
+
* heads-up fires ~700×/day and is anti-correlated with real kills/incidents, so it is
|
|
276
|
+
* pure #alerts noise. This gates ONLY the Discord POST in sendBurstPreAlert(): the
|
|
277
|
+
* burst versions are still queued + scanned (queue.js), and the `[MONITOR] BURST
|
|
278
|
+
* PRE-ALERT` console.log + the `stats.burstPreAlerts` counter (used by the daily
|
|
279
|
+
* summary) are emitted BEFORE the send at the call site, so they are unaffected.
|
|
280
|
+
* Opt back in with MUADDIB_BURST_PREALERT_WEBHOOK=1 (also accepts true/yes/on).
|
|
281
|
+
* Read at call time so a restart re-toggles it without a code change (and tests can flip it).
|
|
282
|
+
*/
|
|
283
|
+
function burstPreAlertWebhookEnabled() {
|
|
284
|
+
const v = (process.env.MUADDIB_BURST_PREALERT_WEBHOOK || '').trim().toLowerCase();
|
|
285
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
286
|
+
}
|
|
287
|
+
|
|
273
288
|
/**
|
|
274
289
|
* Layer 1c: Send a burst pre-alert webhook. Fire-and-forget; callers dedupe per
|
|
275
|
-
* name/window so a burst pings once, not once per version.
|
|
290
|
+
* name/window so a burst pings once, not once per version. Discord POST is muted by
|
|
291
|
+
* default — see burstPreAlertWebhookEnabled(). Scoped to this sender only; the shared
|
|
292
|
+
* sendWebhook() and every other alert type (IOC/campaign pre-alerts, scan results,
|
|
293
|
+
* DEGRADED, daily report) are untouched.
|
|
276
294
|
*/
|
|
277
295
|
async function sendBurstPreAlert(name, count, ecosystem = 'npm') {
|
|
296
|
+
if (!burstPreAlertWebhookEnabled()) return;
|
|
278
297
|
const url = getWebhookUrl();
|
|
279
298
|
if (!url) return;
|
|
280
299
|
await sendWebhook(url, buildBurstPreAlertEmbed(name, count, ecosystem), { rawPayload: true });
|
|
@@ -1739,6 +1758,7 @@ module.exports = {
|
|
|
1739
1758
|
sendCampaignPreAlert,
|
|
1740
1759
|
buildBurstPreAlertEmbed,
|
|
1741
1760
|
sendBurstPreAlert,
|
|
1761
|
+
burstPreAlertWebhookEnabled,
|
|
1742
1762
|
matchVersionedIOC,
|
|
1743
1763
|
computeRiskLevel,
|
|
1744
1764
|
computeRiskScore,
|
|
@@ -668,6 +668,15 @@ const PLAYBOOKS = {
|
|
|
668
668
|
'ou modifie git config init.templateDir pour la persistence globale. ' +
|
|
669
669
|
'Verifier .git/hooks/ et git config --global --list. Supprimer les hooks non reconnus.',
|
|
670
670
|
|
|
671
|
+
electron_app_injection:
|
|
672
|
+
'CRITIQUE: Injection dans une application Electron tierce. Le package localise une app Electron installee ' +
|
|
673
|
+
'(Discord, Atomic Wallet, Slack, ...) via os.homedir()+.asar/discord_desktop_core+existsSync, puis ecrase ' +
|
|
674
|
+
'son code (index.js du core module / app.asar) avec un payload injecte (hook webContents.debugger, override ' +
|
|
675
|
+
'XMLHttpRequest.prototype.send, BrowserWindow) — le payload s\'execute avec l\'identite de l\'app cible pour ' +
|
|
676
|
+
'intercepter credentials/MFA ou detourner des adresses crypto. Verifier l\'integrite de l\'app ciblee ' +
|
|
677
|
+
'(reinstaller depuis la source officielle), rechercher le code injecte dans son core/.asar, revoquer tout ' +
|
|
678
|
+
'credential saisi dans l\'app compromise, et supprimer le package. Considerer le poste comme compromis si le hook a tourne.',
|
|
679
|
+
|
|
671
680
|
env_harvesting_dynamic:
|
|
672
681
|
'Collecte dynamique de variables d\'environnement via Object.entries/keys/values(process.env) ' +
|
|
673
682
|
'avec filtrage par patterns sensibles. Technique de vol de credentials a grande echelle. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -1155,6 +1155,20 @@ const RULES = {
|
|
|
1155
1155
|
],
|
|
1156
1156
|
mitre: 'T1497'
|
|
1157
1157
|
},
|
|
1158
|
+
electron_app_injection: {
|
|
1159
|
+
id: 'MUADDIB-AST-097',
|
|
1160
|
+
name: 'Electron App Injection',
|
|
1161
|
+
severity: 'CRITICAL',
|
|
1162
|
+
confidence: 'high',
|
|
1163
|
+
domain: 'malware',
|
|
1164
|
+
description: 'Un hook d\'installation localise une application Electron tierce deja installee chez la victime (Discord, Atomic Wallet, Slack, ...) — via os.homedir() + un chemin de signature Electron (.asar / discord_desktop_core / module core) + une sonde fs.existsSync — puis ecrase son code avec une charge utile JavaScript injectee, de sorte que le payload s\'execute avec l\'identite et les permissions de l\'application cible. Discriminant AST (vs proxy file-level): l\'ecriture (writeFileSync/appendFileSync) ecrit un CONTENU qui EST du code Electron injecte (BrowserWindow / webContents.debugger / *.prototype.X= / require(\'electron\')). Une app Electron legitime ecrit sa propre config JSON, jamais du code hookant les internals d\'une autre app. CRITICAL quand corrobore par le contexte de decouverte foreign-app; HIGH pour une ecriture nue ciblant un fichier .asar/core sous le home (GT-036 discord-electron-inject, GT-044 atomic-wallet-patch).',
|
|
1165
|
+
references: [
|
|
1166
|
+
'https://research.jfrog.com/post/duer-js-malicious-package/',
|
|
1167
|
+
'https://thehackernews.com/2025/04/malicious-npm-package-targets-atomic.html',
|
|
1168
|
+
'https://attack.mitre.org/techniques/T1554/'
|
|
1169
|
+
],
|
|
1170
|
+
mitre: 'T1554'
|
|
1171
|
+
},
|
|
1158
1172
|
detached_process: {
|
|
1159
1173
|
id: 'MUADDIB-AST-012',
|
|
1160
1174
|
name: 'Detached Background Process',
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MUADDIB-AST-097 — electron_app_injection shared detection helpers.
|
|
4
|
+
//
|
|
5
|
+
// Threat model: an install-time hook LOCATES a third-party Electron desktop app already
|
|
6
|
+
// installed on the victim (Discord, Atomic Wallet, Slack, ...) by resolving the user's home
|
|
7
|
+
// directory, matching an Electron signature path (.asar / *_desktop_core / an electron core
|
|
8
|
+
// module), and probing it with fs.existsSync — then OVERWRITES that app's code with an injected
|
|
9
|
+
// JavaScript payload, so the payload runs with the target app's identity and permissions.
|
|
10
|
+
// GT-036 (discord-electron-inject): overwrites Discord's discord_desktop_core/index.js with a
|
|
11
|
+
// webContents.debugger network hook that exfiltrates login/MFA responses to bada-stealer.com.
|
|
12
|
+
// GT-044 (atomic-wallet-patch): patches Atomic Wallet's app.asar bundle with an
|
|
13
|
+
// XMLHttpRequest.prototype.send hook that swaps crypto recipient addresses.
|
|
14
|
+
//
|
|
15
|
+
// The DISCRIMINATOR (why this beats a file-level co-occurrence proxy): the malice is not that a
|
|
16
|
+
// file *mentions* BrowserWindow/.asar — a legitimate Electron app's own main process mentions all
|
|
17
|
+
// of those. The malice is that a filesystem WRITE writes CONTENT that IS injected Electron code
|
|
18
|
+
// into a FOREIGN app. So we couple, at AST level:
|
|
19
|
+
// (1) the write's CONTENT resolves to injected-Electron-code markers (the payload), AND
|
|
20
|
+
// (2) module context proves foreign-app discovery: os.homedir() + Electron-sig path + existsSync.
|
|
21
|
+
// A legit Electron app writes JSON *config* (settings.json), never source that hooks another app's
|
|
22
|
+
// internals — so coupling the payload to the write is what separates injection from an app writing
|
|
23
|
+
// its own settings, WITHOUT the evadable "is this file itself an Electron main?" heuristic.
|
|
24
|
+
//
|
|
25
|
+
// Consumed by: handle-call-expression.js (write-sink + fact collection), handle-variable-declarator.js
|
|
26
|
+
// & handle-assignment-expression.js (injected-code variable tracking), handle-post-walk.js (emit).
|
|
27
|
+
|
|
28
|
+
const { extractStringValueDeep } = require('./helpers.js');
|
|
29
|
+
|
|
30
|
+
// An Electron *target path* signature: an installed app's packed archive or a native core module.
|
|
31
|
+
// app.asar / core.asar / *.asar(.unpacked) — Electron ASAR archive
|
|
32
|
+
// discord_desktop_core / betterdiscord — Discord's swappable core module (well-known target)
|
|
33
|
+
// modules/<x>_core / *_desktop_core — Electron "<app>_core" native module convention
|
|
34
|
+
const ELECTRON_SIG_RE = /\.asar\b|discord_desktop_core|betterdiscord|[\\/]modules[\\/][A-Za-z0-9_.-]*_core\b|_desktop_core\b/i;
|
|
35
|
+
|
|
36
|
+
// Injected-Electron-code markers — tokens that make written CONTENT an injection payload, not config.
|
|
37
|
+
// HOOK markers (interception / tamper): near-zero in benign written content.
|
|
38
|
+
const ELECTRON_HOOK_MARKERS =
|
|
39
|
+
/\bwebContents\b|\bdebugger\s*\.\s*(?:attach|on|sendCommand)\b|\bNetwork\s*\.\s*(?:enable|responseReceived|getResponseBody)\b|\bipcRenderer\b|\.\s*prototype\s*\.\s*[A-Za-z_$][\w$]*\s*=/;
|
|
40
|
+
// APP markers (electron surface): boilerplate-ish, weaker alone but a payload signal in write content.
|
|
41
|
+
const ELECTRON_APP_MARKERS =
|
|
42
|
+
/\bBrowserWindow\b|require\s*\(\s*['"]electron['"]\s*\)|module\s*\.\s*exports\s*=\s*require\s*\(/;
|
|
43
|
+
// A write's content is a payload when it carries a HOOK marker OR an APP marker.
|
|
44
|
+
const ELECTRON_PAYLOAD_MARKERS = new RegExp(ELECTRON_HOOK_MARKERS.source + '|' + ELECTRON_APP_MARKERS.source);
|
|
45
|
+
|
|
46
|
+
const HOME_PATH_STR_RE = /[\\/]home[\\/]|[\\/]Users[\\/]|appdata|localappdata|userprofile|[\\/]\.config[\\/]|^~[\\/]/i;
|
|
47
|
+
|
|
48
|
+
// Best-effort: collect all string-literal fragments reachable from a node subtree (array
|
|
49
|
+
// elements, `.join()`/`.concat()` receivers+args, `+` concatenation, template quasis, conditional
|
|
50
|
+
// branches). Bounded in depth and total length. Used to test whether a write's CONTENT argument
|
|
51
|
+
// (or a variable initializer) carries injected-Electron-code markers even when it is built as
|
|
52
|
+
// `[ ...lines ].join("\n")` or `"...hook..." + fileContent`.
|
|
53
|
+
function collectCodeString(node, depth, acc) {
|
|
54
|
+
if (!node || depth > 6 || acc.budget <= 0) return acc.s;
|
|
55
|
+
switch (node.type) {
|
|
56
|
+
case 'Literal':
|
|
57
|
+
if (typeof node.value === 'string') { acc.s += node.value + '\n'; acc.budget -= node.value.length + 1; }
|
|
58
|
+
break;
|
|
59
|
+
case 'TemplateLiteral':
|
|
60
|
+
for (const q of node.quasis || []) { const raw = q.value.raw; acc.s += raw + '\n'; acc.budget -= raw.length + 1; }
|
|
61
|
+
for (const e of node.expressions || []) collectCodeString(e, depth + 1, acc);
|
|
62
|
+
break;
|
|
63
|
+
case 'BinaryExpression':
|
|
64
|
+
if (node.operator === '+') { collectCodeString(node.left, depth + 1, acc); collectCodeString(node.right, depth + 1, acc); }
|
|
65
|
+
break;
|
|
66
|
+
case 'ArrayExpression':
|
|
67
|
+
for (const el of node.elements || []) collectCodeString(el, depth + 1, acc);
|
|
68
|
+
break;
|
|
69
|
+
case 'CallExpression': {
|
|
70
|
+
const m = node.callee && node.callee.type === 'MemberExpression' && node.callee.property
|
|
71
|
+
? node.callee.property.name : null;
|
|
72
|
+
if (m === 'join' || m === 'concat') {
|
|
73
|
+
collectCodeString(node.callee.object, depth + 1, acc);
|
|
74
|
+
for (const a of node.arguments || []) collectCodeString(a, depth + 1, acc);
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'ConditionalExpression':
|
|
79
|
+
collectCodeString(node.consequent, depth + 1, acc);
|
|
80
|
+
collectCodeString(node.alternate, depth + 1, acc);
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
return acc.s;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveCodeString(node) {
|
|
89
|
+
return collectCodeString(node, 0, { s: '', budget: 20000 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Does this subtree reference a variable already known to hold injected Electron code?
|
|
93
|
+
// Handles `hook + "\n" + fileContent` (BinaryExpression), templates, and array joins.
|
|
94
|
+
function referencesInjectedVar(node, ctx, depth) {
|
|
95
|
+
if (!node || depth > 6 || !ctx.injectedCodeVars) return false;
|
|
96
|
+
switch (node.type) {
|
|
97
|
+
case 'Identifier':
|
|
98
|
+
return ctx.injectedCodeVars.has(node.name);
|
|
99
|
+
case 'BinaryExpression':
|
|
100
|
+
return node.operator === '+' &&
|
|
101
|
+
(referencesInjectedVar(node.left, ctx, depth + 1) || referencesInjectedVar(node.right, ctx, depth + 1));
|
|
102
|
+
case 'TemplateLiteral':
|
|
103
|
+
return (node.expressions || []).some(e => referencesInjectedVar(e, ctx, depth + 1));
|
|
104
|
+
case 'ConditionalExpression':
|
|
105
|
+
return referencesInjectedVar(node.consequent, ctx, depth + 1) || referencesInjectedVar(node.alternate, ctx, depth + 1);
|
|
106
|
+
case 'CallExpression': {
|
|
107
|
+
const m = node.callee && node.callee.type === 'MemberExpression' && node.callee.property
|
|
108
|
+
? node.callee.property.name : null;
|
|
109
|
+
if (m === 'join' || m === 'concat') {
|
|
110
|
+
return referencesInjectedVar(node.callee.object, ctx, depth + 1) ||
|
|
111
|
+
(node.arguments || []).some(a => referencesInjectedVar(a, ctx, depth + 1));
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// True if a node (a variable initializer or a write's content arg) carries an injected payload:
|
|
121
|
+
// it references a tracked injected-code variable, or it statically resolves to code with markers.
|
|
122
|
+
function carriesInjectedPayload(node, ctx) {
|
|
123
|
+
if (!node) return false;
|
|
124
|
+
if (node.type === 'Identifier') return !!(ctx.injectedCodeVars && ctx.injectedCodeVars.has(node.name));
|
|
125
|
+
if (referencesInjectedVar(node, ctx, 0)) return true;
|
|
126
|
+
return ELECTRON_PAYLOAD_MARKERS.test(resolveCodeString(node));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// An argument that roots a path in the user's HOME (where a victim's apps are installed):
|
|
130
|
+
// os.homedir() | process.env.{HOME,APPDATA,LOCALAPPDATA,USERPROFILE,XDG_CONFIG_HOME}
|
|
131
|
+
// | "~"/"~/..." | absolute "/home/..", "/Users/..", "C:\Users\.." literal
|
|
132
|
+
function isHomeRootArg(node) {
|
|
133
|
+
if (!node) return false;
|
|
134
|
+
if (node.type === 'CallExpression' && node.callee && node.callee.type === 'MemberExpression' &&
|
|
135
|
+
node.callee.object && node.callee.object.type === 'Identifier' && node.callee.object.name === 'os' &&
|
|
136
|
+
node.callee.property && node.callee.property.type === 'Identifier' && node.callee.property.name === 'homedir') {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (node.type === 'MemberExpression' &&
|
|
140
|
+
node.object && node.object.type === 'MemberExpression' &&
|
|
141
|
+
node.object.object && node.object.object.type === 'Identifier' && node.object.object.name === 'process' &&
|
|
142
|
+
node.object.property && node.object.property.type === 'Identifier' && node.object.property.name === 'env' &&
|
|
143
|
+
node.property && node.property.type === 'Identifier' &&
|
|
144
|
+
['HOME', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'XDG_CONFIG_HOME'].includes(node.property.name)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
148
|
+
if (/^~(?:[\\/]|$)/.test(node.value)) return true;
|
|
149
|
+
if (/^\/home\/|^\/Users\/|:[\\/]Users[\\/]/i.test(node.value)) return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _scanPathArgForSig(arg, ctx) {
|
|
155
|
+
if (!arg) return;
|
|
156
|
+
const s = extractStringValueDeep(arg);
|
|
157
|
+
if (s && ELECTRON_SIG_RE.test(s)) ctx.hasElectronAppSig = true;
|
|
158
|
+
if (arg.type === 'CallExpression' && arg.callee && arg.callee.type === 'MemberExpression' &&
|
|
159
|
+
arg.callee.object && arg.callee.object.type === 'Identifier' && arg.callee.object.name === 'path' &&
|
|
160
|
+
arg.callee.property && arg.callee.property.type === 'Identifier' &&
|
|
161
|
+
(arg.callee.property.name === 'join' || arg.callee.property.name === 'resolve')) {
|
|
162
|
+
const joined = (arg.arguments || []).map(a => extractStringValueDeep(a) || '').join('/');
|
|
163
|
+
if (ELECTRON_SIG_RE.test(joined)) ctx.hasElectronAppSig = true;
|
|
164
|
+
if ((arg.arguments || []).some(isHomeRootArg)) ctx.hasHomedirResolve = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Collect the foreign-app-discovery corroboration facts from a CallExpression:
|
|
169
|
+
// os.homedir() → hasHomedirResolve
|
|
170
|
+
// fs.existsSync/accessSync/statSync/lstatSync → hasFsExistsProbe (+ sig/home from the probed path)
|
|
171
|
+
// path.join/path.resolve → hasElectronAppSig / hasHomedirResolve
|
|
172
|
+
// app.whenReady/app.getPath/.loadURL/.loadFile → hasOwnElectronMain (the file IS an Electron main;
|
|
173
|
+
// gates the HIGH tier so a real app's self-writes
|
|
174
|
+
// are not flagged as tampering).
|
|
175
|
+
function scanElectronFacts(node, ctx) {
|
|
176
|
+
const callee = node.callee;
|
|
177
|
+
if (!callee || callee.type !== 'MemberExpression' || !callee.property || callee.property.type !== 'Identifier') return;
|
|
178
|
+
const prop = callee.property.name;
|
|
179
|
+
const objName = callee.object && callee.object.type === 'Identifier' ? callee.object.name : null;
|
|
180
|
+
|
|
181
|
+
if (objName === 'os' && prop === 'homedir') ctx.hasHomedirResolve = true;
|
|
182
|
+
|
|
183
|
+
if (prop === 'existsSync' || prop === 'accessSync' || prop === 'statSync' || prop === 'lstatSync') {
|
|
184
|
+
ctx.hasFsExistsProbe = true;
|
|
185
|
+
_scanPathArgForSig(node.arguments && node.arguments[0], ctx);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (objName === 'path' && (prop === 'join' || prop === 'resolve')) {
|
|
189
|
+
const joined = (node.arguments || []).map(a => extractStringValueDeep(a) || '').join('/');
|
|
190
|
+
if (ELECTRON_SIG_RE.test(joined)) ctx.hasElectronAppSig = true;
|
|
191
|
+
if ((node.arguments || []).some(isHomeRootArg)) ctx.hasHomedirResolve = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if ((prop === 'whenReady' && (objName === 'app' || objName === 'electron')) ||
|
|
195
|
+
(prop === 'getPath' && objName === 'app') ||
|
|
196
|
+
prop === 'loadURL' || prop === 'loadFile') {
|
|
197
|
+
ctx.hasOwnElectronMain = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// For the HIGH tier: does a write TARGET resolve to a home-rooted Electron file? Handles an inline
|
|
202
|
+
// path.join/path.resolve, a bare literal, or an Identifier bound to a tracked path.join result
|
|
203
|
+
// (ctx.electronTargetVars — the common `const target = path.join(os.homedir(), ..., 'app.asar')`).
|
|
204
|
+
function resolveElectronTarget(node, ctx) {
|
|
205
|
+
if (node && node.type === 'Identifier' && ctx && ctx.electronTargetVars && ctx.electronTargetVars.has(node.name)) {
|
|
206
|
+
return ctx.electronTargetVars.get(node.name);
|
|
207
|
+
}
|
|
208
|
+
let pathStr = '';
|
|
209
|
+
let home = false;
|
|
210
|
+
if (node && node.type === 'CallExpression' && node.callee && node.callee.type === 'MemberExpression' &&
|
|
211
|
+
node.callee.object && node.callee.object.type === 'Identifier' && node.callee.object.name === 'path' &&
|
|
212
|
+
node.callee.property && node.callee.property.type === 'Identifier' &&
|
|
213
|
+
(node.callee.property.name === 'join' || node.callee.property.name === 'resolve')) {
|
|
214
|
+
pathStr = (node.arguments || []).map(a => extractStringValueDeep(a) || '').join('/');
|
|
215
|
+
if ((node.arguments || []).some(isHomeRootArg)) home = true;
|
|
216
|
+
} else {
|
|
217
|
+
pathStr = extractStringValueDeep(node) || '';
|
|
218
|
+
}
|
|
219
|
+
if (HOME_PATH_STR_RE.test(pathStr)) home = true;
|
|
220
|
+
return { sig: ELECTRON_SIG_RE.test(pathStr), home, pathStr: pathStr.slice(0, 120) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
ELECTRON_SIG_RE,
|
|
225
|
+
ELECTRON_PAYLOAD_MARKERS,
|
|
226
|
+
ELECTRON_HOOK_MARKERS,
|
|
227
|
+
resolveCodeString,
|
|
228
|
+
carriesInjectedPayload,
|
|
229
|
+
referencesInjectedVar,
|
|
230
|
+
isHomeRootArg,
|
|
231
|
+
scanElectronFacts,
|
|
232
|
+
resolveElectronTarget
|
|
233
|
+
};
|
|
@@ -11,10 +11,18 @@ const {
|
|
|
11
11
|
resolveStringConcat,
|
|
12
12
|
extractStringValueDeep
|
|
13
13
|
} = require('./helpers.js');
|
|
14
|
+
const { carriesInjectedPayload } = require('./electron-injection.js');
|
|
14
15
|
|
|
15
16
|
function handleAssignmentExpression(node, ctx) {
|
|
16
17
|
// Variable reassignment: x += 'process' or x = x + 'process'
|
|
17
18
|
if (node.left?.type === 'Identifier') {
|
|
19
|
+
// MUADDIB-AST-097: propagate injected-Electron-code taint across reassignment. Covers
|
|
20
|
+
// `content = hook + "\n" + content` where `hook` is a payload array-join, so the subsequent
|
|
21
|
+
// writeFileSync(target, content) is recognised as writing an injected payload.
|
|
22
|
+
if ((node.operator === '=' || node.operator === '+=') &&
|
|
23
|
+
carriesInjectedPayload(node.right, ctx)) {
|
|
24
|
+
ctx.injectedCodeVars.add(node.left.name);
|
|
25
|
+
}
|
|
18
26
|
if (node.operator === '+=' && ctx.stringVarValues.has(node.left.name)) {
|
|
19
27
|
const rightVal = extractStringValueDeep(node.right);
|
|
20
28
|
if (rightVal !== null) {
|
|
@@ -96,6 +96,11 @@ const {
|
|
|
96
96
|
const { countInvisibleUnicode } = require('../../shared/unicode-invisibles.js');
|
|
97
97
|
const { classifyMcpWrite } = require('./mcp-write-classifier.js');
|
|
98
98
|
const { isShadowEnabled, recordShadowDivergence } = require('../../shared/shadow.js');
|
|
99
|
+
const {
|
|
100
|
+
carriesInjectedPayload,
|
|
101
|
+
scanElectronFacts,
|
|
102
|
+
resolveElectronTarget
|
|
103
|
+
} = require('./electron-injection.js');
|
|
99
104
|
|
|
100
105
|
/**
|
|
101
106
|
* SHADOW 3-tier classification for mcp_config_injection emissions (R5 + R5b).
|
|
@@ -172,6 +177,11 @@ function _isUserLevelPathArg(node, depth = 0) {
|
|
|
172
177
|
function handleCallExpression(node, ctx) {
|
|
173
178
|
const callName = getCallName(node);
|
|
174
179
|
|
|
180
|
+
// MUADDIB-AST-097: collect foreign-Electron-app-discovery facts (os.homedir / existsSync /
|
|
181
|
+
// .asar-electron-core path.join / own-app markers). Correlated with the payload-write in
|
|
182
|
+
// handlePostWalk to emit electron_app_injection.
|
|
183
|
+
scanElectronFacts(node, ctx);
|
|
184
|
+
|
|
175
185
|
// Detect require() with non-literal argument (obfuscation)
|
|
176
186
|
if (callName === 'require' && node.arguments.length > 0) {
|
|
177
187
|
const arg = node.arguments[0];
|
|
@@ -967,6 +977,28 @@ function handleCallExpression(node, ctx) {
|
|
|
967
977
|
}
|
|
968
978
|
|
|
969
979
|
|
|
980
|
+
// MUADDIB-AST-097: electron_app_injection — a filesystem write whose CONTENT resolves to
|
|
981
|
+
// injected Electron code (BrowserWindow / webContents-debugger hook / prototype override /
|
|
982
|
+
// require('electron')). The payload-in-write coupling is the AST discriminator vs a legit
|
|
983
|
+
// Electron app writing JSON config. The foreign-app-discovery corroboration (os.homedir +
|
|
984
|
+
// .asar/electron-core sig + existsSync) and the final verdict are assembled in handlePostWalk.
|
|
985
|
+
// A home-rooted .asar/electron-core *target* write (payload not statically resolvable) is
|
|
986
|
+
// recorded separately for the HIGH tier.
|
|
987
|
+
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
988
|
+
const eaiMethod = node.callee.property.name;
|
|
989
|
+
if (['writeFileSync', 'writeFile', 'appendFileSync', 'appendFile'].includes(eaiMethod) &&
|
|
990
|
+
node.arguments.length >= 2) {
|
|
991
|
+
if (carriesInjectedPayload(node.arguments[1], ctx)) {
|
|
992
|
+
ctx.electronPayloadWrites.push({ method: eaiMethod });
|
|
993
|
+
} else {
|
|
994
|
+
const tgt = resolveElectronTarget(node.arguments[0], ctx);
|
|
995
|
+
if (tgt.sig && tgt.home) {
|
|
996
|
+
ctx.electronAsarTargetWrites.push({ method: eaiMethod, pathStr: tgt.pathStr });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
970
1002
|
// Detect fs.chmodSync with executable permissions (deferred to postWalk for compound check)
|
|
971
1003
|
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
972
1004
|
const chmodMethod = node.callee.property.name;
|
|
@@ -640,6 +640,39 @@ function handlePostWalk(ctx) {
|
|
|
640
640
|
});
|
|
641
641
|
}
|
|
642
642
|
}
|
|
643
|
+
|
|
644
|
+
// MUADDIB-AST-097: electron_app_injection — an install hook overwrites a THIRD-PARTY Electron
|
|
645
|
+
// app's core/.asar with injected code (GT-036 Discord debugger-hook stealer → bada-stealer.com;
|
|
646
|
+
// GT-044 Atomic Wallet XMLHttpRequest.prototype.send address-swap). The discriminating AST
|
|
647
|
+
// coupling (assembled here from facts collected during the walk): a writeFileSync whose CONTENT
|
|
648
|
+
// resolves to injected Electron code (electronPayloadWrites), corroborated by the foreign-app
|
|
649
|
+
// discovery context — os.homedir() + a .asar/electron-core signature path + an existsSync probe.
|
|
650
|
+
// A legit Electron app writes JSON *config*, never source that hooks another app's internals, so
|
|
651
|
+
// the payload-in-write coupling is what separates injection from an app writing its own settings
|
|
652
|
+
// (no need for the evadable "is this file itself an Electron main?" exclusion). dist/bundled
|
|
653
|
+
// files are suppressed: an app's own minified main naturally carries these tokens.
|
|
654
|
+
const _eaiDist = /^(?:dist|build|out|output|vendor)[/\\]/i.test(ctx.relFile) ||
|
|
655
|
+
/\.(?:bundle|min)\.js$/i.test(ctx.relFile);
|
|
656
|
+
if (!_eaiDist) {
|
|
657
|
+
const foreignAppDiscovery = ctx.hasHomedirResolve && ctx.hasElectronAppSig && ctx.hasFsExistsProbe;
|
|
658
|
+
if ((ctx.electronPayloadWrites?.length || 0) > 0 && foreignAppDiscovery) {
|
|
659
|
+
const w = ctx.electronPayloadWrites[0];
|
|
660
|
+
ctx.threats.push({
|
|
661
|
+
type: 'electron_app_injection',
|
|
662
|
+
severity: 'CRITICAL',
|
|
663
|
+
message: `Electron app injection: ${w.method}() overwrites a foreign Electron app — located via os.homedir()+.asar/electron-core+existsSync — with injected code (BrowserWindow/webContents-debugger/prototype-override/require('electron')). Third-party desktop-app code injection (Discord/Atomic-Wallet class): the payload runs with the target app's identity.`,
|
|
664
|
+
file: ctx.relFile
|
|
665
|
+
});
|
|
666
|
+
} else if ((ctx.electronAsarTargetWrites?.length || 0) > 0 && !ctx.hasOwnElectronMain) {
|
|
667
|
+
const w = ctx.electronAsarTargetWrites[0];
|
|
668
|
+
ctx.threats.push({
|
|
669
|
+
type: 'electron_app_injection',
|
|
670
|
+
severity: 'HIGH',
|
|
671
|
+
message: `Electron app tampering: ${w.method}() overwrites a user-home Electron app file "${w.pathStr}" (.asar/electron-core). Writing into an installed desktop app is an injection/patching technique; the injected payload was not statically resolvable.`,
|
|
672
|
+
file: ctx.relFile
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
643
676
|
}
|
|
644
677
|
|
|
645
678
|
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
extractStringValueDeep,
|
|
17
17
|
isStaticValue
|
|
18
18
|
} = require('./helpers.js');
|
|
19
|
+
const { carriesInjectedPayload, resolveElectronTarget } = require('./electron-injection.js');
|
|
19
20
|
|
|
20
21
|
function handleVariableDeclarator(node, ctx) {
|
|
21
22
|
if (node.id?.type === 'Identifier') {
|
|
@@ -24,6 +25,24 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
24
25
|
ctx.staticAssignments.add(node.id.name);
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// MUADDIB-AST-097: track variables whose value is injected Electron code (the write payload).
|
|
29
|
+
// Covers `var injectedPayload = [ "...BrowserWindow...", ... ].join("\n")` and any concat /
|
|
30
|
+
// template / literal carrying payload markers. Consumed by the write-sink check + post-walk.
|
|
31
|
+
if (node.init && carriesInjectedPayload(node.init, ctx)) {
|
|
32
|
+
ctx.injectedCodeVars.add(node.id.name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// MUADDIB-AST-097: track variables bound to a path.join/resolve that builds an Electron app
|
|
36
|
+
// file path (for the HIGH-tier bare-.asar-target write, where the write target is a variable,
|
|
37
|
+
// e.g. `const target = path.join(os.homedir(), '.config', 'discord', 'app.asar')`).
|
|
38
|
+
if (node.init && node.init.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression' &&
|
|
39
|
+
node.init.callee.object?.type === 'Identifier' && node.init.callee.object.name === 'path' &&
|
|
40
|
+
node.init.callee.property?.type === 'Identifier' &&
|
|
41
|
+
(node.init.callee.property.name === 'join' || node.init.callee.property.name === 'resolve')) {
|
|
42
|
+
const eaiTgt = resolveElectronTarget(node.init);
|
|
43
|
+
if (eaiTgt.sig) ctx.electronTargetVars.set(node.id.name, eaiTgt);
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// AST-018 alias capture (@longzy): `var env = process.env` — record the alias so a later
|
|
28
47
|
// `env[String.fromCharCode(...)]` access resolves back to process.env in the member handler.
|
|
29
48
|
if (ctx.envAliases && node.init?.type === 'MemberExpression' && !node.init.computed &&
|
package/src/scanner/ast.js
CHANGED
|
@@ -158,6 +158,16 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
158
158
|
objectPropertyMap: new Map(), // B5: objName → Map<propName, stringValue>
|
|
159
159
|
concatValues: new Map(), // B2: varName → { value, operands } for concat strings with ≥3 operands
|
|
160
160
|
stringVarValues: new Map(), // Variable reassignment tracking: varName → string value
|
|
161
|
+
// MUADDIB-AST-097 electron_app_injection: injected-code variable taint + write-sink records +
|
|
162
|
+
// foreign-Electron-app-discovery facts (os.homedir + .asar/electron-core sig + existsSync probe).
|
|
163
|
+
injectedCodeVars: new Set(), // vars whose value is injected Electron code (write payload)
|
|
164
|
+
electronTargetVars: new Map(), // varName → {sig,home,pathStr} for a path.join Electron target
|
|
165
|
+
electronPayloadWrites: [], // writeFile* calls whose CONTENT is an injected payload
|
|
166
|
+
electronAsarTargetWrites: [], // writeFile* calls whose TARGET is a home-rooted .asar/core file
|
|
167
|
+
hasHomedirResolve: false, // os.homedir() / process.env.{HOME,APPDATA,...} resolved
|
|
168
|
+
hasElectronAppSig: false, // a .asar / *_desktop_core / electron-core path constructed
|
|
169
|
+
hasFsExistsProbe: false, // fs.existsSync/accessSync/statSync (probe the victim install)
|
|
170
|
+
hasOwnElectronMain: false, // file IS an Electron main (app.whenReady/loadURL/...) — gates HIGH
|
|
161
171
|
hasFromCharCode: content.includes('fromCharCode'),
|
|
162
172
|
// Anti-analysis / sandbox evasion (2026, @longzy DPRK). Structural "detonation-gate wall"
|
|
163
173
|
// magnitude + host recon + a charcode-hidden analyzer-honeytoken reference. Aggregated in
|