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.
- package/README.md +10 -7
- package/package.json +3 -1
- package/src/integrations/registry-signals.js +216 -0
- package/src/pipeline/processor.js +190 -13
- package/src/response/playbooks.js +34 -0
- package/src/rules/confidence-tiers.js +187 -0
- package/src/rules/index.js +89 -2
- package/src/runtime/monitor-feed.js +241 -0
- package/src/runtime/serve.js +59 -2
- package/src/sandbox/compound-triggers.js +232 -0
- package/src/scanner/ast-detectors/handle-assignment-expression.js +7 -2
- package/src/scanner/ast.js +18 -0
- package/src/scanner/npm-registry.js +31 -1
- package/src/scanner/reachability.js +603 -1
- package/src/scanner/typosquat.js +6 -2
- package/src/scoring/delta-multiplier.js +294 -0
- package/src/scoring.js +387 -4
|
@@ -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
|
-
|
|
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: `${
|
|
249
|
+
message: `${targetName}.prototype.${left.property?.name || '?'} overridden — native API hooking for traffic interception.`,
|
|
245
250
|
file: ctx.relFile
|
|
246
251
|
});
|
|
247
252
|
}
|
package/src/scanner/ast.js
CHANGED
|
@@ -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
|
|