muaddib-scanner 2.11.4 → 2.11.7

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.
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ // Sandbox-friendly compound triggers.
4
+ // Surgical activation of the Docker sandbox only on threat patterns where
5
+ // dynamic observation provides signal beyond static AST/regex analysis.
6
+ // Targets 2026 attacks: Shai-Hulud, Axios 2026 (OrDeR_7077), GlassWorm,
7
+ // PhantomRaven, CanisterWorm, ltidi chain.
8
+ //
9
+ // Activation rule: a compound matches AND preliminary score in [15, 35].
10
+ // score < 15 -> clean, no need to sandbox
11
+ // score > 35 -> already definitive, no second-tier verdict needed
12
+
13
+ const SANDBOX_TRIGGER_MIN_SCORE = 15;
14
+ const SANDBOX_TRIGGER_MAX_SCORE = 35;
15
+
16
+ const TRIGGERS = [
17
+ {
18
+ name: 'lifecycle_install_chain',
19
+ description: 'Lifecycle script + credential tampering or harvest pattern',
20
+ target: 'Shai-Hulud, PhantomRaven',
21
+ watchpoints: ['honey_npmrc_read', 'honey_ssh_read', 'execve_chain_depth', 'outbound_non_registry'],
22
+ matches(threats) {
23
+ const hasLifecycle = threats.some(t =>
24
+ t.type === 'lifecycle_script' ||
25
+ t.type === 'lifecycle_added_critical' ||
26
+ t.type === 'lifecycle_added_high' ||
27
+ t.type === 'lifecycle_modified' ||
28
+ t.type === 'lifecycle_inline_exec' ||
29
+ t.type === 'lifecycle_remote_require' ||
30
+ t.type === 'lifecycle_dataflow' ||
31
+ t.type === 'lifecycle_dangerous_exec' ||
32
+ t.type === 'obfuscated_lifecycle_env' ||
33
+ t.type === 'lifecycle_typosquat' ||
34
+ t.type === 'lifecycle_shell_pipe' ||
35
+ t.type === 'lifecycle_hidden_payload'
36
+ );
37
+ const hasCredHarvest = threats.some(t =>
38
+ t.type === 'credential_regex_harvest' ||
39
+ t.type === 'credential_tampering' ||
40
+ t.type === 'credential_command_exec' ||
41
+ t.type === 'env_harvesting_dynamic' ||
42
+ t.type === 'curl_env_exfil' ||
43
+ t.type === 'env_proxy_intercept' ||
44
+ t.type === 'npmrc_access' ||
45
+ t.type === 'github_token_access' ||
46
+ t.type === 'aws_credential_access' ||
47
+ t.type === 'ssh_access'
48
+ );
49
+ return hasLifecycle && hasCredHarvest;
50
+ }
51
+ },
52
+ {
53
+ name: 'stub_with_external_dep',
54
+ description: 'Stub package with external HTTPS dep (ltidi chain)',
55
+ target: 'ltidi chain attack',
56
+ watchpoints: ['outbound_non_registry', 'fs_created_outside_install', 'execve_chain_depth'],
57
+ matches(threats) {
58
+ const hasStub = threats.some(t =>
59
+ t.type === 'stub_package_external_payload' ||
60
+ t.type === 'stub_package_external_dep' ||
61
+ t.type === 'stub_with_string_ioc'
62
+ );
63
+ const hasExternalDep = threats.some(t =>
64
+ t.type === 'external_tarball_dep' ||
65
+ t.type === 'dependency_url_suspicious' ||
66
+ t.type === 'git_dependency_rce' ||
67
+ t.type === 'lifecycle_script_dependency'
68
+ );
69
+ return hasStub && hasExternalDep;
70
+ }
71
+ },
72
+ {
73
+ name: 'obfuscated_oversize',
74
+ description: 'Obfuscation + large file + execution path',
75
+ target: 'Shai-Hulud bun_environment.js (10MB)',
76
+ watchpoints: ['runtime_deobfuscation_executed', 'execve_chain_depth', 'outbound_non_registry'],
77
+ matches(threats, fileSizes) {
78
+ const hasObf = threats.some(t =>
79
+ t.type === 'obfuscation_detected' ||
80
+ t.type === 'js_obfuscation_pattern' ||
81
+ t.type === 'possible_obfuscation' ||
82
+ t.type === 'split_entropy_payload' ||
83
+ t.type === 'fragmented_high_entropy_cluster' ||
84
+ t.type === 'high_entropy_string'
85
+ );
86
+ const hasExec = threats.some(t =>
87
+ t.type === 'dangerous_call_exec' ||
88
+ t.type === 'dangerous_exec' ||
89
+ t.type === 'detached_process' ||
90
+ t.type === 'staged_payload' ||
91
+ t.type === 'staged_binary_payload' ||
92
+ t.type === 'binary_dropper' ||
93
+ t.type === 'bun_runtime_evasion'
94
+ );
95
+ if (!hasObf || !hasExec) return false;
96
+ // Specificity gate: this compound only matches when at least one file
97
+ // exceeds 1MB. Without that, decrypt_then_execute below is more
98
+ // appropriate. Returns false (not undefined) when no size info to keep
99
+ // the more specific decrypt_then_execute match available.
100
+ if (!fileSizes || Object.keys(fileSizes).length === 0) return false;
101
+ return Object.values(fileSizes).some(size => typeof size === 'number' && size > 1024 * 1024);
102
+ }
103
+ },
104
+ {
105
+ name: 'decrypt_then_execute',
106
+ description: 'Obfuscation or XOR decoding + new Function or eval',
107
+ target: 'Axios 2026 OrDeR_7077',
108
+ watchpoints: ['runtime_deobfuscation_executed', 'outbound_non_registry'],
109
+ matches(threats) {
110
+ const hasDecrypt = threats.some(t =>
111
+ t.type === 'base64_decode' ||
112
+ t.type === 'base64_decode_exec' ||
113
+ t.type === 'obfuscation_detected' ||
114
+ t.type === 'js_obfuscation_pattern' ||
115
+ t.type === 'crypto_decipher' ||
116
+ t.type === 'staged_eval_decode' ||
117
+ t.type === 'env_charcode_reconstruction' ||
118
+ t.type === 'string_mutation_obfuscation' ||
119
+ t.type === 'self_destruct_eval' ||
120
+ t.type === 'anti_forensic_xor_autodelete' ||
121
+ t.type === 'anti_forensic_partial' ||
122
+ t.type === 'wget_base64_decode'
123
+ );
124
+ const hasExec = threats.some(t =>
125
+ t.type === 'dangerous_call_eval' ||
126
+ t.type === 'dangerous_call_function' ||
127
+ t.type === 'dangerous_constructor' ||
128
+ t.type === 'function_runtime_args' ||
129
+ t.type === 'function_constructor_require' ||
130
+ t.type === 'staged_payload' ||
131
+ t.type === 'fetch_decrypt_exec' ||
132
+ t.type === 'vm_dynamic_code' ||
133
+ t.type === 'vm_code_execution' ||
134
+ t.type === 'reflect_code_execution' ||
135
+ t.type === 'callback_exec_rce' ||
136
+ t.type === 'eval_usage'
137
+ );
138
+ return hasDecrypt && hasExec;
139
+ }
140
+ },
141
+ {
142
+ name: 'invisible_blockchain',
143
+ description: 'Unicode invisible decoder + blockchain RPC endpoint',
144
+ target: 'GlassWorm',
145
+ watchpoints: ['outbound_blockchain_rpc', 'runtime_deobfuscation_executed'],
146
+ matches(threats) {
147
+ const hasInvisible = threats.some(t =>
148
+ t.type === 'unicode_invisible_injection' ||
149
+ t.type === 'unicode_variation_decoder'
150
+ );
151
+ const hasBlockchain = threats.some(t =>
152
+ t.type === 'blockchain_c2_resolution' ||
153
+ t.type === 'blockchain_rpc_endpoint'
154
+ );
155
+ return hasInvisible && hasBlockchain;
156
+ }
157
+ },
158
+ {
159
+ name: 'npm_token_self_use',
160
+ description: 'npmrc access + outbound HTTP or npm CLI invocation pattern',
161
+ target: 'CanisterWorm',
162
+ watchpoints: ['npm_self_invoke', 'honey_npmrc_read', 'outbound_non_registry'],
163
+ matches(threats) {
164
+ const hasNpmrc = threats.some(t =>
165
+ t.type === 'npmrc_access' ||
166
+ t.type === 'npmrc_git_override' ||
167
+ t.type === 'npm_token_steal' ||
168
+ t.type === 'npm_publish_worm'
169
+ );
170
+ const hasOutbound = threats.some(t =>
171
+ t.type === 'curl_exfiltration' ||
172
+ t.type === 'curl_env_exfil' ||
173
+ t.type === 'github_api_call' ||
174
+ t.type === 'remote_code_load' ||
175
+ t.type === 'network_require' ||
176
+ t.type === 'websocket_credential_exfil' ||
177
+ t.type === 'websocket_c2' ||
178
+ t.type === 'dns_chunk_exfiltration' ||
179
+ t.type === 'staged_payload' ||
180
+ t.type === 'fetch_decrypt_exec'
181
+ );
182
+ return hasNpmrc && hasOutbound;
183
+ }
184
+ }
185
+ ];
186
+
187
+ /**
188
+ * Evaluate whether the static threat set warrants sandbox activation.
189
+ *
190
+ * @param {Array<{type:string,severity:string}>} threats - Deduplicated static threats.
191
+ * @param {number} score - Preliminary static score.
192
+ * @param {object} [fileSizes] - Map relative-path -> bytes (used by obfuscated_oversize).
193
+ * @returns {{shouldRun:boolean, compound:string|null, watchpoints:string[], reason:string}}
194
+ */
195
+ function evaluateSandboxTrigger(threats, score, fileSizes) {
196
+ if (!Array.isArray(threats)) {
197
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'no threats array' };
198
+ }
199
+ if (typeof score !== 'number' || Number.isNaN(score)) {
200
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'invalid score' };
201
+ }
202
+ if (score < SANDBOX_TRIGGER_MIN_SCORE) {
203
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'score below window' };
204
+ }
205
+ if (score > SANDBOX_TRIGGER_MAX_SCORE) {
206
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'score above window' };
207
+ }
208
+ for (const trigger of TRIGGERS) {
209
+ let matched = false;
210
+ try {
211
+ matched = trigger.matches(threats, fileSizes || {});
212
+ } catch (e) {
213
+ matched = false;
214
+ }
215
+ if (matched) {
216
+ return {
217
+ shouldRun: true,
218
+ compound: trigger.name,
219
+ watchpoints: trigger.watchpoints.slice(),
220
+ reason: trigger.description
221
+ };
222
+ }
223
+ }
224
+ return { shouldRun: false, compound: null, watchpoints: [], reason: 'no compound matched' };
225
+ }
226
+
227
+ module.exports = {
228
+ evaluateSandboxTrigger,
229
+ TRIGGERS,
230
+ SANDBOX_TRIGGER_MIN_SCORE,
231
+ SANDBOX_TRIGGER_MAX_SCORE
232
+ };
@@ -237,11 +237,16 @@ function handleAssignmentExpression(node, ctx) {
237
237
  left.object.property?.type === 'Identifier' &&
238
238
  left.object.property.name === 'prototype' &&
239
239
  left.object.object?.type === 'Identifier') {
240
- if (HOOKABLE_NATIVES.includes(left.object.object.name)) {
240
+ const targetName = left.object.object.name;
241
+ // FPR plan : skip when the target name is a local declaration. A file
242
+ // that does `function Request () {}` then `Request.prototype.X = ...`
243
+ // is implementing its own class, not hijacking the Fetch API.
244
+ const isSelfHook = ctx.localClassNames && ctx.localClassNames.has(targetName);
245
+ if (HOOKABLE_NATIVES.includes(targetName) && !isSelfHook) {
241
246
  ctx.threats.push({
242
247
  type: 'prototype_hook',
243
248
  severity: 'HIGH',
244
- message: `${left.object.object.name}.prototype.${left.property?.name || '?'} overridden — native API hooking for traffic interception.`,
249
+ message: `${targetName}.prototype.${left.property?.name || '?'} overridden — native API hooking for traffic interception.`,
245
250
  file: ctx.relFile
246
251
  });
247
252
  }
@@ -119,6 +119,24 @@ function analyzeFile(content, filePath, basePath) {
119
119
  workflowPathVars: new Set(),
120
120
  execPathVars: new Map(),
121
121
  globalThisAliases: new Set(),
122
+ // FPR plan : prototype_hook on a name like `Request` or `WebSocket` is
123
+ // a strong malice signal when it targets the *global* (Fetch / native)
124
+ // class. When the file declares its OWN class with the same name, the
125
+ // hook is just self-instrumentation (sl-request, ws's own Server, etc.).
126
+ // Pre-compute the set of locally declared class / function-constructor
127
+ // names via a cheap regex so handle-assignment-expression can skip the
128
+ // self-hook pattern. Matches : `function X (`, `class X {`, `class X(`,
129
+ // `const X = function`, `let X = class`, etc.
130
+ localClassNames: (() => {
131
+ const names = new Set();
132
+ const re = /\b(?:function|class)\s+(\w+)\s*[(\{]|\b(?:const|let|var)\s+(\w+)\s*=\s*(?:function|class)\b/g;
133
+ let m;
134
+ while ((m = re.exec(content)) !== null) {
135
+ const id = m[1] || m[2];
136
+ if (id) names.add(id);
137
+ }
138
+ return names;
139
+ })(),
122
140
  evalAliases: new Map(), // B1: variable name → 'eval'|'Function'
123
141
  moduleLoadDirectAliases: new Set(), // B3: destructured _load from require('module')
124
142
  objectPropertyMap: new Map(), // B5: objName → Map<propName, stringValue>
@@ -1,6 +1,7 @@
1
1
  const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
2
2
  const { debugLog } = require('../utils.js');
3
3
  const { acquireRegistrySlot, releaseRegistrySlot } = require('../shared/http-limiter.js');
4
+ const { computeAdvancedRegistrySignals } = require('../integrations/registry-signals.js');
4
5
 
5
6
  const REGISTRY_URL = 'https://registry.npmjs.org';
6
7
  const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
@@ -145,6 +146,27 @@ async function getPackageMetadata(packageName) {
145
146
  const description = (typeof latestMeta?.description === 'string' ? latestMeta.description
146
147
  : (typeof meta.description === 'string' ? meta.description : ''));
147
148
 
149
+ let advancedSignals = {};
150
+ try {
151
+ advancedSignals = computeAdvancedRegistrySignals(meta);
152
+ } catch (err) {
153
+ debugLog('[registry-signals] failed for ' + packageName + ': ' + err.message);
154
+ }
155
+
156
+ // FPR plan Chantier 3 : the delta multiplier needs the per-version publish
157
+ // timeline to pick the 3 versions immediately preceding the scanned one.
158
+ // We export it as a compact { version : ISO string } map so consumers don't
159
+ // have to re-fetch the packument. Skip the meta-keys "created" / "modified"
160
+ // - those are top-level package timestamps, not version timestamps.
161
+ const versionTimes = {};
162
+ if (meta.time && typeof meta.time === 'object') {
163
+ for (const [k, v] of Object.entries(meta.time)) {
164
+ if (k === 'created' || k === 'modified') continue;
165
+ if (typeof v !== 'string') continue;
166
+ versionTimes[k] = v;
167
+ }
168
+ }
169
+
148
170
  return {
149
171
  created_at: createdAt,
150
172
  age_days: ageDays,
@@ -154,7 +176,15 @@ async function getPackageMetadata(packageName) {
154
176
  has_repository: hasRepository,
155
177
  version_count: versionCount,
156
178
  readme_size: readmeText.length,
157
- description
179
+ description,
180
+ // FPR plan : the "live latest" version for the package, used by the mature
181
+ // stable cap to fire only when the scanned version IS this one. Historical
182
+ // / pinned-old / vendored versions bypass the cap so we don't mask attacks
183
+ // captured in static fixtures (e.g. eslint-scope 3.7.2, chalk 5.6.1).
184
+ latest_version: latestVersion || null,
185
+ // C3 : per-version publish timestamps for delta-mode selectPriorVersions.
186
+ time: versionTimes,
187
+ ...advancedSignals
158
188
  };
159
189
  }
160
190