@xagents_in/unmask 0.1.3 → 0.1.4
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 +86 -4
- package/dist/cli/annotations.mjs +145 -0
- package/dist/cli/audit.mjs +223 -0
- package/dist/cli/cache.mjs +488 -0
- package/dist/cli/compare.mjs +261 -0
- package/dist/cli/config.mjs +141 -0
- package/dist/cli/discovery.mjs +372 -0
- package/dist/cli/doctor.mjs +354 -0
- package/dist/cli/exceptions.mjs +170 -0
- package/dist/cli/file.mjs +271 -0
- package/dist/cli/files.mjs +141 -0
- package/dist/cli/history.mjs +232 -0
- package/dist/cli/hosted.mjs +251 -43
- package/dist/cli/info.mjs +125 -0
- package/dist/cli/init.mjs +57 -0
- package/dist/cli/install-guard.mjs +896 -0
- package/dist/cli/lockfile.mjs +330 -0
- package/dist/cli/main.mjs +97 -14
- package/dist/cli/preflight.mjs +388 -0
- package/dist/cli/sarif.mjs +100 -0
- package/dist/cli/shell-integration.mjs +122 -0
- package/dist/cli/terminal.mjs +396 -0
- package/dist/cli/watch.mjs +233 -0
- package/dist/cli/why.mjs +256 -0
- package/dist/core/advisories.mjs +101 -24
- package/dist/core/advisory-cache.mjs +295 -0
- package/dist/core/behavior-signals.mjs +1088 -0
- package/dist/core/dependency-graph.mjs +332 -0
- package/dist/core/evidence-exports.mjs +469 -0
- package/dist/core/guard-config.mjs +463 -0
- package/dist/core/guard-policy.mjs +308 -0
- package/dist/core/package-details.mjs +42 -6
- package/dist/core/report.mjs +7 -0
- package/dist/core/sarif.mjs +556 -0
- package/dist/core/syntax.mjs +296 -15
- package/dist/model/client.mjs +204 -56
- package/dist/model/diff-evidence.mjs +177 -0
- package/dist/model/summary.mjs +183 -0
- package/dist/server/advisory-result.mjs +137 -0
- package/dist/server/app.mjs +60 -2
- package/dist/server/dependency-screening.mjs +386 -0
- package/dist/server/exports.mjs +63 -0
- package/dist/server/guard-decision.mjs +37 -0
- package/dist/server/main.mjs +25 -7
- package/dist/server/queue.mjs +95 -53
- package/dist/server/store.mjs +21 -1
- package/dist/version.json +1 -1
- package/dist/viewer/render.mjs +10 -2
- package/dist/viewer/shell.mjs +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
import { parseExpressionAt } from "acorn";
|
|
2
|
+
|
|
3
|
+
export const BEHAVIOR_LIMITS = Object.freeze({
|
|
4
|
+
manifestBytes: 262144,
|
|
5
|
+
scriptBytes: 8192,
|
|
6
|
+
scriptTokens: 512,
|
|
7
|
+
literalBytes: 4096,
|
|
8
|
+
linkedScripts: 16,
|
|
9
|
+
linkedScriptBytes: 65536,
|
|
10
|
+
linkedScriptTokens: 8192,
|
|
11
|
+
reviewCues: 128,
|
|
12
|
+
reviewCuesPerFileRule: 8,
|
|
13
|
+
highCues: 128,
|
|
14
|
+
});
|
|
15
|
+
export const INSTALL_HOOKS = Object.freeze([
|
|
16
|
+
"preinstall",
|
|
17
|
+
"install",
|
|
18
|
+
"postinstall",
|
|
19
|
+
]);
|
|
20
|
+
const SUMMARIES = {
|
|
21
|
+
"behavior-install-network-fetch":
|
|
22
|
+
"Install hook text references a download tool or URL; a network fetch during install is suspected, not proven. Target code was not executed.",
|
|
23
|
+
"behavior-install-remote-shell":
|
|
24
|
+
"Install hook text appears to run downloaded content (piped into an interpreter, or saved and then executed); this is a suspicion from static text, not observed execution.",
|
|
25
|
+
"behavior-install-inline-execution":
|
|
26
|
+
"Install hook text runs inline interpreter code; the executed content is not proven and was not run.",
|
|
27
|
+
"behavior-install-credential-access":
|
|
28
|
+
"Install hook text references credential files or secret environment values; access is suspected, not proven.",
|
|
29
|
+
"behavior-credential-path-reference":
|
|
30
|
+
"String names a credential-store path; reading or sending it is not proven.",
|
|
31
|
+
"behavior-sensitive-environment-read":
|
|
32
|
+
"Code references a secret-like environment variable; use and disclosure are not proven.",
|
|
33
|
+
"behavior-environment-enumeration":
|
|
34
|
+
"Code enumerates or serializes the whole process environment; disclosure is not proven.",
|
|
35
|
+
"behavior-encoded-dynamic-code":
|
|
36
|
+
"Decoded data appears to flow into dynamic code evaluation; this is a static suspicion and nothing was evaluated.",
|
|
37
|
+
"behavior-encoded-process-execution":
|
|
38
|
+
"Decoded data appears to flow into child process execution; this is a static suspicion and nothing was executed.",
|
|
39
|
+
"behavior-encoded-module-load":
|
|
40
|
+
"A module name is statically decoded before being required; loading and use are not proven.",
|
|
41
|
+
"behavior-recursive-overwrite":
|
|
42
|
+
"A recursive directory walker that writes or deletes files is called on a root, parent, home or decoded path; destructive overwrite is suspected from static structure, not observed.",
|
|
43
|
+
"behavior-process-remote-shell":
|
|
44
|
+
"A child process command string pipes downloaded content into an interpreter; this is a static suspicion, not observed execution.",
|
|
45
|
+
};
|
|
46
|
+
const NETWORK_TOOLS = new Set([
|
|
47
|
+
"curl",
|
|
48
|
+
"wget",
|
|
49
|
+
"invoke-webrequest",
|
|
50
|
+
"iwr",
|
|
51
|
+
"invoke-restmethod",
|
|
52
|
+
"irm",
|
|
53
|
+
"bitsadmin",
|
|
54
|
+
"certutil",
|
|
55
|
+
]);
|
|
56
|
+
const INTERPRETERS = new Set([
|
|
57
|
+
"sh",
|
|
58
|
+
"bash",
|
|
59
|
+
"zsh",
|
|
60
|
+
"dash",
|
|
61
|
+
"node",
|
|
62
|
+
"python",
|
|
63
|
+
"python3",
|
|
64
|
+
"perl",
|
|
65
|
+
"ruby",
|
|
66
|
+
"powershell",
|
|
67
|
+
"pwsh",
|
|
68
|
+
"iex",
|
|
69
|
+
"invoke-expression",
|
|
70
|
+
]);
|
|
71
|
+
const PREFIXES = new Set(["sudo", "env", "exec", "command", "nohup"]);
|
|
72
|
+
const INLINE_FLAGS = {
|
|
73
|
+
node: ["-e", "--eval", "-p", "--print"],
|
|
74
|
+
sh: ["-c"],
|
|
75
|
+
bash: ["-c"],
|
|
76
|
+
zsh: ["-c"],
|
|
77
|
+
dash: ["-c"],
|
|
78
|
+
python: ["-c"],
|
|
79
|
+
python3: ["-c"],
|
|
80
|
+
perl: ["-e"],
|
|
81
|
+
ruby: ["-e"],
|
|
82
|
+
powershell: ["-c", "-command", "-e", "-enc", "-encodedcommand"],
|
|
83
|
+
pwsh: ["-c", "-command", "-e", "-enc", "-encodedcommand"],
|
|
84
|
+
cmd: ["/c", "/k"],
|
|
85
|
+
"cmd.exe": ["/c", "/k"],
|
|
86
|
+
};
|
|
87
|
+
const CREDENTIAL_PATHS = [
|
|
88
|
+
".npmrc",
|
|
89
|
+
".yarnrc",
|
|
90
|
+
".pypirc",
|
|
91
|
+
".netrc",
|
|
92
|
+
".git-credentials",
|
|
93
|
+
".ssh/",
|
|
94
|
+
"id_rsa",
|
|
95
|
+
"id_ed25519",
|
|
96
|
+
"id_ecdsa",
|
|
97
|
+
".aws/credentials",
|
|
98
|
+
".docker/config.json",
|
|
99
|
+
".kube/config",
|
|
100
|
+
".config/gcloud",
|
|
101
|
+
".azure/",
|
|
102
|
+
".gnupg",
|
|
103
|
+
"/etc/shadow",
|
|
104
|
+
"/etc/passwd",
|
|
105
|
+
".bash_history",
|
|
106
|
+
".zsh_history",
|
|
107
|
+
"login data",
|
|
108
|
+
"wallet.dat",
|
|
109
|
+
];
|
|
110
|
+
const SECRET_NAME =
|
|
111
|
+
/TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL|_AUTH/i;
|
|
112
|
+
const ENUMERATORS = new Set([
|
|
113
|
+
"JSON.stringify",
|
|
114
|
+
"Object.keys",
|
|
115
|
+
"Object.entries",
|
|
116
|
+
"Object.values",
|
|
117
|
+
"Object.getOwnPropertyNames",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
export const summaryFor = (ruleId) => SUMMARIES[ruleId];
|
|
121
|
+
const base = (word) =>
|
|
122
|
+
word
|
|
123
|
+
.slice(Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\")) + 1)
|
|
124
|
+
.replace(/\.exe$/, "");
|
|
125
|
+
const credentialIn = (text) => {
|
|
126
|
+
const lower = text.toLowerCase();
|
|
127
|
+
return CREDENTIAL_PATHS.filter((item) => lower.includes(item));
|
|
128
|
+
};
|
|
129
|
+
function tokens(script, limit = BEHAVIOR_LIMITS.scriptTokens) {
|
|
130
|
+
const result = [];
|
|
131
|
+
let word = "";
|
|
132
|
+
const flush = () => {
|
|
133
|
+
if (word) result.push(word);
|
|
134
|
+
word = "";
|
|
135
|
+
};
|
|
136
|
+
for (const character of script) {
|
|
137
|
+
if (result.length > limit) return null;
|
|
138
|
+
if (character === "\n" || character === "\r") {
|
|
139
|
+
flush();
|
|
140
|
+
result.push(";");
|
|
141
|
+
} else if (/\s/.test(character) || character === '"' || character === "'")
|
|
142
|
+
flush();
|
|
143
|
+
else if ("|;&<>()`".includes(character)) {
|
|
144
|
+
flush();
|
|
145
|
+
result.push(character);
|
|
146
|
+
} else word += character;
|
|
147
|
+
}
|
|
148
|
+
flush();
|
|
149
|
+
return result.length > limit ? null : result;
|
|
150
|
+
}
|
|
151
|
+
function secretVariables(script) {
|
|
152
|
+
const names = [];
|
|
153
|
+
let index = 0;
|
|
154
|
+
while ((index = script.indexOf("$", index)) !== -1 && names.length < 8) {
|
|
155
|
+
let start = index + 1;
|
|
156
|
+
if (script[start] === "{") start++;
|
|
157
|
+
let end = start;
|
|
158
|
+
while (end < script.length && /[A-Za-z0-9_]/.test(script[end])) end++;
|
|
159
|
+
const name = script.slice(start, end);
|
|
160
|
+
if (SECRET_NAME.test(name)) names.push(name);
|
|
161
|
+
index = end;
|
|
162
|
+
}
|
|
163
|
+
return names;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const OUTPUT_FLAGS = new Set([
|
|
167
|
+
"-o",
|
|
168
|
+
"--output",
|
|
169
|
+
"--output-document",
|
|
170
|
+
"-outfile",
|
|
171
|
+
]);
|
|
172
|
+
const COMMAND_BREAKS = new Set([";", "&", "|", "("]);
|
|
173
|
+
const RUNNERS = new Set([
|
|
174
|
+
"start",
|
|
175
|
+
"start-process",
|
|
176
|
+
"regsvr32",
|
|
177
|
+
"rundll32",
|
|
178
|
+
"mshta",
|
|
179
|
+
"cscript",
|
|
180
|
+
"wscript",
|
|
181
|
+
]);
|
|
182
|
+
const SCRIPT_FILE = /\.(sh|bash|bat|cmd|ps1)$/i;
|
|
183
|
+
function linkedScripts(raw) {
|
|
184
|
+
const found = new Set();
|
|
185
|
+
for (const word of raw) {
|
|
186
|
+
const path = word.replace(/^\.[\\/]/, "").replace(/[\\/]+/g, "/");
|
|
187
|
+
if (
|
|
188
|
+
SCRIPT_FILE.test(path) &&
|
|
189
|
+
/^[\w@.][\w@./-]*$/.test(path) &&
|
|
190
|
+
!path.split("/").includes("..")
|
|
191
|
+
)
|
|
192
|
+
found.add(path);
|
|
193
|
+
}
|
|
194
|
+
return [...found].slice(0, 16);
|
|
195
|
+
}
|
|
196
|
+
function downloadedFile(words, firstNetwork) {
|
|
197
|
+
if (firstNetwork < 0) return null;
|
|
198
|
+
const outputs = new Set();
|
|
199
|
+
for (let i = firstNetwork; i + 1 < words.length; i++) {
|
|
200
|
+
if (OUTPUT_FLAGS.has(words[i]) || words[i] === ">")
|
|
201
|
+
outputs.add(words[i + 1].replace(/^\.[\\/]/, ""));
|
|
202
|
+
if (base(words[i]) !== "certutil") continue;
|
|
203
|
+
for (let at = i + 1; at + 1 < words.length; at++) {
|
|
204
|
+
if (COMMAND_BREAKS.has(words[at])) break;
|
|
205
|
+
if (/^https?:\/\//.test(words[at]) && !COMMAND_BREAKS.has(words[at + 1]))
|
|
206
|
+
outputs.add(words[at + 1].replace(/^\.[\\/]/, ""));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (let i = firstNetwork + 1; i < words.length; i++) {
|
|
210
|
+
const word = words[i].replace(/^\.[\\/]/, "");
|
|
211
|
+
if (!outputs.has(word)) continue;
|
|
212
|
+
const before = words[i - 1];
|
|
213
|
+
const ranAsCommand =
|
|
214
|
+
COMMAND_BREAKS.has(before) &&
|
|
215
|
+
(words[i] !== word || /\.(exe|bat|cmd|ps1)$/.test(word));
|
|
216
|
+
const ranByInterpreter =
|
|
217
|
+
INTERPRETERS.has(base(before)) ||
|
|
218
|
+
RUNNERS.has(base(before)) ||
|
|
219
|
+
(RUNNERS.has(base(words[i - 2] ?? "")) && before.startsWith("-"));
|
|
220
|
+
if (ranAsCommand || ranByInterpreter) return word;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function scriptSignals(
|
|
226
|
+
script,
|
|
227
|
+
{
|
|
228
|
+
maxBytes = BEHAVIOR_LIMITS.scriptBytes,
|
|
229
|
+
maxTokens = BEHAVIOR_LIMITS.scriptTokens,
|
|
230
|
+
} = {},
|
|
231
|
+
) {
|
|
232
|
+
if (typeof script !== "string" || Buffer.byteLength(script) > maxBytes)
|
|
233
|
+
return { status: "not-assessed", reason: "script-byte-cap", signals: [] };
|
|
234
|
+
let words = tokens(script, maxTokens);
|
|
235
|
+
if (!words)
|
|
236
|
+
return { status: "not-assessed", reason: "script-token-cap", signals: [] };
|
|
237
|
+
const raw = words;
|
|
238
|
+
words = raw.map((word) => word.toLowerCase());
|
|
239
|
+
const signals = [];
|
|
240
|
+
const network = [
|
|
241
|
+
...new Set(words.filter((word) => NETWORK_TOOLS.has(base(word))).map(base)),
|
|
242
|
+
];
|
|
243
|
+
const urls = raw.filter((word) => /^https?:\/\//i.test(word)).slice(0, 4);
|
|
244
|
+
const firstNetwork = words.findIndex((word) => NETWORK_TOOLS.has(base(word)));
|
|
245
|
+
let piped = null;
|
|
246
|
+
for (let i = firstNetwork + 1; i < words.length && firstNetwork >= 0; i++) {
|
|
247
|
+
if (words[i] !== "|") continue;
|
|
248
|
+
let next = i + 1;
|
|
249
|
+
while (
|
|
250
|
+
next < words.length &&
|
|
251
|
+
(PREFIXES.has(base(words[next])) ||
|
|
252
|
+
words[next].startsWith("-") ||
|
|
253
|
+
/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[next]))
|
|
254
|
+
)
|
|
255
|
+
next++;
|
|
256
|
+
if (INTERPRETERS.has(base(words[next] ?? ""))) piped = base(words[next]);
|
|
257
|
+
}
|
|
258
|
+
for (let i = 0; i < firstNetwork && !piped; i++) {
|
|
259
|
+
const opener =
|
|
260
|
+
words[i] === "`" ||
|
|
261
|
+
(["$", "<"].includes(words[i]) && words[i + 1] === "(");
|
|
262
|
+
const shell = words
|
|
263
|
+
.slice(0, i)
|
|
264
|
+
.findLast((word) => INTERPRETERS.has(base(word)));
|
|
265
|
+
if (opener && shell) piped = base(shell);
|
|
266
|
+
}
|
|
267
|
+
const saved = downloadedFile(words, firstNetwork);
|
|
268
|
+
if (!piped && saved) piped = "downloaded-file";
|
|
269
|
+
if (network.length && piped)
|
|
270
|
+
signals.push({
|
|
271
|
+
ruleId: "behavior-install-remote-shell",
|
|
272
|
+
priority: "HIGH",
|
|
273
|
+
tools: network,
|
|
274
|
+
interpreter: piped,
|
|
275
|
+
...(piped === "downloaded-file" ? { file: saved } : {}),
|
|
276
|
+
});
|
|
277
|
+
else if (network.length || urls.length)
|
|
278
|
+
signals.push({
|
|
279
|
+
ruleId: "behavior-install-network-fetch",
|
|
280
|
+
priority: "review",
|
|
281
|
+
tools: network,
|
|
282
|
+
urls,
|
|
283
|
+
});
|
|
284
|
+
const inline = [];
|
|
285
|
+
for (let i = 0; i + 1 < words.length; i++) {
|
|
286
|
+
const flags = INLINE_FLAGS[base(words[i])];
|
|
287
|
+
if (flags?.includes(words[i + 1]))
|
|
288
|
+
inline.push(`${base(words[i])} ${words[i + 1]}`);
|
|
289
|
+
}
|
|
290
|
+
if (inline.length)
|
|
291
|
+
signals.push({
|
|
292
|
+
ruleId: "behavior-install-inline-execution",
|
|
293
|
+
priority: "review",
|
|
294
|
+
commands: [...new Set(inline)].slice(0, 4),
|
|
295
|
+
});
|
|
296
|
+
const paths = credentialIn(script);
|
|
297
|
+
const variables = secretVariables(script);
|
|
298
|
+
if (paths.length || variables.length)
|
|
299
|
+
signals.push({
|
|
300
|
+
ruleId: "behavior-install-credential-access",
|
|
301
|
+
priority: "review",
|
|
302
|
+
paths,
|
|
303
|
+
variables,
|
|
304
|
+
});
|
|
305
|
+
const targets = [];
|
|
306
|
+
for (let i = 0; i < words.length; i++) {
|
|
307
|
+
if (base(words[i]) !== "node") continue;
|
|
308
|
+
let next = i + 1;
|
|
309
|
+
while (next < words.length && words[next].startsWith("-")) {
|
|
310
|
+
if (INLINE_FLAGS.node.includes(words[next])) next = words.length;
|
|
311
|
+
next++;
|
|
312
|
+
}
|
|
313
|
+
const target = raw[next]?.replace(/^\.\//, "");
|
|
314
|
+
if (target && /^[\w@.][\w@./-]*$/.test(target) && !target.includes(".."))
|
|
315
|
+
targets.push(
|
|
316
|
+
...(/\.[cm]?js$/i.test(target)
|
|
317
|
+
? [target]
|
|
318
|
+
: [target, ...[".js", ".cjs", ".mjs"].map((ext) => target + ext)]),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
status: "assessed",
|
|
323
|
+
signals,
|
|
324
|
+
targets: targets.slice(0, 16),
|
|
325
|
+
scripts: linkedScripts(raw),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function manifestScriptSpans(text) {
|
|
330
|
+
if (Buffer.byteLength(text) > BEHAVIOR_LIMITS.manifestBytes)
|
|
331
|
+
return { status: "not-assessed", reason: "manifest-byte-cap", hooks: [] };
|
|
332
|
+
let root;
|
|
333
|
+
try {
|
|
334
|
+
root = parseExpressionAt(text, 0, { ecmaVersion: 2025 });
|
|
335
|
+
} catch {
|
|
336
|
+
return {
|
|
337
|
+
status: "not-assessed",
|
|
338
|
+
reason: "manifest-parse-failed",
|
|
339
|
+
hooks: [],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const key = (property) =>
|
|
343
|
+
property.type === "Property" && property.key.type === "Literal"
|
|
344
|
+
? property.key.value
|
|
345
|
+
: undefined;
|
|
346
|
+
const scripts =
|
|
347
|
+
root.type === "ObjectExpression" &&
|
|
348
|
+
root.properties.findLast((item) => key(item) === "scripts")?.value;
|
|
349
|
+
const hooks = [];
|
|
350
|
+
if (scripts?.type === "ObjectExpression")
|
|
351
|
+
for (const hook of INSTALL_HOOKS) {
|
|
352
|
+
const value = scripts.properties.findLast(
|
|
353
|
+
(item) => key(item) === hook,
|
|
354
|
+
)?.value;
|
|
355
|
+
if (value?.type === "Literal" && typeof value.value === "string")
|
|
356
|
+
hooks.push({
|
|
357
|
+
hook,
|
|
358
|
+
script: value.value,
|
|
359
|
+
start: value.start,
|
|
360
|
+
end: value.end,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return { status: "assessed", hooks };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isProcessEnv(node) {
|
|
367
|
+
return (
|
|
368
|
+
node?.type === "MemberExpression" &&
|
|
369
|
+
!node.computed &&
|
|
370
|
+
node.object.type === "Identifier" &&
|
|
371
|
+
node.object.name === "process" &&
|
|
372
|
+
node.property.name === "env"
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
function calleeName(node) {
|
|
376
|
+
if (node.type === "Identifier") return node.name;
|
|
377
|
+
if (
|
|
378
|
+
node.type === "MemberExpression" &&
|
|
379
|
+
!node.computed &&
|
|
380
|
+
node.object.type === "Identifier"
|
|
381
|
+
)
|
|
382
|
+
return `${node.object.name}.${node.property.name}`;
|
|
383
|
+
return "";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function javascriptSignal(node) {
|
|
387
|
+
if (
|
|
388
|
+
(node.type === "Literal" && typeof node.value === "string") ||
|
|
389
|
+
node.type === "TemplateElement"
|
|
390
|
+
) {
|
|
391
|
+
const value = node.type === "Literal" ? node.value : node.value.cooked;
|
|
392
|
+
if (
|
|
393
|
+
typeof value !== "string" ||
|
|
394
|
+
Buffer.byteLength(value) > BEHAVIOR_LIMITS.literalBytes
|
|
395
|
+
)
|
|
396
|
+
return null;
|
|
397
|
+
const paths = credentialIn(value);
|
|
398
|
+
return paths.length
|
|
399
|
+
? { ruleId: "behavior-credential-path-reference", paths }
|
|
400
|
+
: null;
|
|
401
|
+
}
|
|
402
|
+
if (node.type === "MemberExpression" && isProcessEnv(node.object)) {
|
|
403
|
+
const name = node.computed
|
|
404
|
+
? node.property.type === "Literal" &&
|
|
405
|
+
typeof node.property.value === "string"
|
|
406
|
+
? node.property.value
|
|
407
|
+
: null
|
|
408
|
+
: node.property.name;
|
|
409
|
+
return name && SECRET_NAME.test(name)
|
|
410
|
+
? { ruleId: "behavior-sensitive-environment-read", variable: name }
|
|
411
|
+
: null;
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
node.type === "CallExpression" &&
|
|
415
|
+
ENUMERATORS.has(calleeName(node.callee)) &&
|
|
416
|
+
isProcessEnv(node.arguments[0])
|
|
417
|
+
)
|
|
418
|
+
return {
|
|
419
|
+
ruleId: "behavior-environment-enumeration",
|
|
420
|
+
via: calleeName(node.callee),
|
|
421
|
+
};
|
|
422
|
+
if (node.type === "ForInStatement" && isProcessEnv(node.right))
|
|
423
|
+
return {
|
|
424
|
+
ruleId: "behavior-environment-enumeration",
|
|
425
|
+
via: "for-in",
|
|
426
|
+
at: node.right,
|
|
427
|
+
};
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const OBFUSCATION_LIMITS = Object.freeze({
|
|
432
|
+
sinksPerFile: 1024,
|
|
433
|
+
subtreeNodes: 64,
|
|
434
|
+
subtreeDepth: 8,
|
|
435
|
+
bindingHops: 2,
|
|
436
|
+
payloadBytes: 65536,
|
|
437
|
+
payloadMinLength: 40,
|
|
438
|
+
charCodes: 4096,
|
|
439
|
+
});
|
|
440
|
+
const DYNAMIC_SINKS = new Set([
|
|
441
|
+
"eval",
|
|
442
|
+
"globalThis.eval",
|
|
443
|
+
"global.eval",
|
|
444
|
+
"window.eval",
|
|
445
|
+
"Function",
|
|
446
|
+
"globalThis.Function",
|
|
447
|
+
"vm.runInThisContext",
|
|
448
|
+
"vm.runInNewContext",
|
|
449
|
+
"vm.runInContext",
|
|
450
|
+
"vm.compileFunction",
|
|
451
|
+
"vm.Script",
|
|
452
|
+
]);
|
|
453
|
+
const PROCESS_SINKS = new Set(
|
|
454
|
+
[
|
|
455
|
+
"exec",
|
|
456
|
+
"execSync",
|
|
457
|
+
"spawn",
|
|
458
|
+
"spawnSync",
|
|
459
|
+
"execFile",
|
|
460
|
+
"execFileSync",
|
|
461
|
+
"fork",
|
|
462
|
+
].map((name) => `child_process.${name}`),
|
|
463
|
+
);
|
|
464
|
+
const DECODERS = new Set([
|
|
465
|
+
"atob",
|
|
466
|
+
"globalThis.atob",
|
|
467
|
+
"window.atob",
|
|
468
|
+
"Buffer.from",
|
|
469
|
+
"Buffer",
|
|
470
|
+
"String.fromCharCode",
|
|
471
|
+
"unescape",
|
|
472
|
+
"decodeURIComponent",
|
|
473
|
+
"zlib.inflateSync",
|
|
474
|
+
"zlib.inflateRawSync",
|
|
475
|
+
"zlib.gunzipSync",
|
|
476
|
+
"zlib.unzipSync",
|
|
477
|
+
"zlib.brotliDecompressSync",
|
|
478
|
+
]);
|
|
479
|
+
const ENCODINGS = new Set(["base64", "base64url", "hex"]);
|
|
480
|
+
const SENSITIVE_MODULES = new Set([
|
|
481
|
+
"child_process",
|
|
482
|
+
"vm",
|
|
483
|
+
"fs",
|
|
484
|
+
"http",
|
|
485
|
+
"https",
|
|
486
|
+
"net",
|
|
487
|
+
"dns",
|
|
488
|
+
"os",
|
|
489
|
+
"worker_threads",
|
|
490
|
+
]);
|
|
491
|
+
const DECODED_HINTS = [
|
|
492
|
+
"child_process",
|
|
493
|
+
"require(",
|
|
494
|
+
"eval(",
|
|
495
|
+
"Function(",
|
|
496
|
+
"http://",
|
|
497
|
+
"https://",
|
|
498
|
+
"process.env",
|
|
499
|
+
".npmrc",
|
|
500
|
+
"/bin/sh",
|
|
501
|
+
];
|
|
502
|
+
const MODULES = new Set(["child_process", "vm", "zlib", "module"]);
|
|
503
|
+
|
|
504
|
+
function stringLiteral(node) {
|
|
505
|
+
return node?.type === "Literal" && typeof node.value === "string"
|
|
506
|
+
? node.value
|
|
507
|
+
: null;
|
|
508
|
+
}
|
|
509
|
+
function requiredModule(node) {
|
|
510
|
+
return node?.type === "CallExpression" &&
|
|
511
|
+
node.callee.type === "Identifier" &&
|
|
512
|
+
node.callee.name === "require" &&
|
|
513
|
+
node.arguments.length === 1
|
|
514
|
+
? stringLiteral(node.arguments[0])?.replace(/^node:/, "")
|
|
515
|
+
: undefined;
|
|
516
|
+
}
|
|
517
|
+
function qualified(node, aliases, depth = 0) {
|
|
518
|
+
if (!node || depth > 4) return "";
|
|
519
|
+
if (node.type === "Identifier") return aliases.get(node.name) ?? node.name;
|
|
520
|
+
const module = requiredModule(node);
|
|
521
|
+
if (module !== undefined) return module;
|
|
522
|
+
if (node.type === "MemberExpression") {
|
|
523
|
+
const property = node.computed
|
|
524
|
+
? stringLiteral(node.property)
|
|
525
|
+
: node.property.name;
|
|
526
|
+
const object = qualified(node.object, aliases, depth + 1);
|
|
527
|
+
return property && object ? `${object}.${property}` : "";
|
|
528
|
+
}
|
|
529
|
+
return "";
|
|
530
|
+
}
|
|
531
|
+
export const NETWORK_MODULES = new Set(["http", "https", "net"]);
|
|
532
|
+
export function moduleAliases(all, modules = MODULES) {
|
|
533
|
+
const aliases = new Map();
|
|
534
|
+
for (const node of all) {
|
|
535
|
+
if (node.type !== "VariableDeclarator" || !node.init) continue;
|
|
536
|
+
const module = requiredModule(node.init);
|
|
537
|
+
if (!modules.has(module)) continue;
|
|
538
|
+
if (node.id.type === "Identifier") aliases.set(node.id.name, module);
|
|
539
|
+
else if (node.id.type === "ObjectPattern")
|
|
540
|
+
for (const property of node.id.properties)
|
|
541
|
+
if (
|
|
542
|
+
property.type === "Property" &&
|
|
543
|
+
!property.computed &&
|
|
544
|
+
property.key.type === "Identifier" &&
|
|
545
|
+
property.value.type === "Identifier"
|
|
546
|
+
)
|
|
547
|
+
aliases.set(property.value.name, `${module}.${property.key.name}`);
|
|
548
|
+
}
|
|
549
|
+
for (const node of all)
|
|
550
|
+
if (node.type === "ImportDeclaration") {
|
|
551
|
+
const module = stringLiteral(node.source)?.replace(/^node:/, "");
|
|
552
|
+
if (!modules.has(module)) continue;
|
|
553
|
+
for (const item of node.specifiers)
|
|
554
|
+
aliases.set(
|
|
555
|
+
item.local.name,
|
|
556
|
+
item.type === "ImportSpecifier"
|
|
557
|
+
? `${module}.${item.imported.name ?? item.imported.value}`
|
|
558
|
+
: module,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return aliases;
|
|
562
|
+
}
|
|
563
|
+
function singleBindings(all) {
|
|
564
|
+
const counts = new Map(),
|
|
565
|
+
inits = new Map();
|
|
566
|
+
for (const node of all)
|
|
567
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") {
|
|
568
|
+
counts.set(node.id.name, (counts.get(node.id.name) || 0) + 1);
|
|
569
|
+
if (node.init) inits.set(node.id.name, node.init);
|
|
570
|
+
} else if (
|
|
571
|
+
node.type === "AssignmentExpression" &&
|
|
572
|
+
node.left.type === "Identifier"
|
|
573
|
+
)
|
|
574
|
+
counts.set(node.left.name, (counts.get(node.left.name) || 0) + 2);
|
|
575
|
+
for (const [name, count] of counts) if (count !== 1) inits.delete(name);
|
|
576
|
+
return inits;
|
|
577
|
+
}
|
|
578
|
+
function isEncodedText(value) {
|
|
579
|
+
return (
|
|
580
|
+
value.length >= OBFUSCATION_LIMITS.payloadMinLength &&
|
|
581
|
+
value.length <= OBFUSCATION_LIMITS.payloadBytes &&
|
|
582
|
+
/^[A-Za-z0-9+/=_-]+$/.test(value)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
function decoderOf(node, aliases) {
|
|
586
|
+
if (!["CallExpression", "NewExpression"].includes(node.type)) return null;
|
|
587
|
+
const name = qualified(node.callee, aliases);
|
|
588
|
+
if (name.startsWith("decoder-helper:")) {
|
|
589
|
+
const [, helper, encoding] = name.split(":");
|
|
590
|
+
return `${helper}():${encoding}`;
|
|
591
|
+
}
|
|
592
|
+
if (!DECODERS.has(name)) return null;
|
|
593
|
+
if (name === "Buffer.from" || name === "Buffer") {
|
|
594
|
+
const encoding = stringLiteral(node.arguments[1]);
|
|
595
|
+
return ENCODINGS.has(encoding) ? `${name}:${encoding}` : null;
|
|
596
|
+
}
|
|
597
|
+
if (name === "unescape" || name === "decodeURIComponent") {
|
|
598
|
+
const value = stringLiteral(node.arguments[0]);
|
|
599
|
+
return value &&
|
|
600
|
+
value.length >= OBFUSCATION_LIMITS.payloadMinLength &&
|
|
601
|
+
value.includes("%")
|
|
602
|
+
? name
|
|
603
|
+
: null;
|
|
604
|
+
}
|
|
605
|
+
return name;
|
|
606
|
+
}
|
|
607
|
+
function literalArgument(node, bindings) {
|
|
608
|
+
const first = node.arguments[0];
|
|
609
|
+
const bound = first?.type === "Identifier" && bindings?.get(first.name);
|
|
610
|
+
return stringLiteral(bound) !== null ? bound : first;
|
|
611
|
+
}
|
|
612
|
+
function decodeLiteral(decoder, node, bindings) {
|
|
613
|
+
if (decoder === "String.fromCharCode") {
|
|
614
|
+
if (node.arguments.length > OBFUSCATION_LIMITS.charCodes) return null;
|
|
615
|
+
const codes = node.arguments.map((item) =>
|
|
616
|
+
item.type === "Literal" && Number.isInteger(item.value) ? item.value : -1,
|
|
617
|
+
);
|
|
618
|
+
return codes.every((code) => code >= 0 && code <= 0xffff)
|
|
619
|
+
? String.fromCharCode(...codes)
|
|
620
|
+
: null;
|
|
621
|
+
}
|
|
622
|
+
const value = stringLiteral(literalArgument(node, bindings));
|
|
623
|
+
if (value === null || value.length > OBFUSCATION_LIMITS.payloadBytes)
|
|
624
|
+
return null;
|
|
625
|
+
if (decoder === "unescape" || decoder === "decodeURIComponent")
|
|
626
|
+
try {
|
|
627
|
+
return decodeURIComponent(value);
|
|
628
|
+
} catch {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (["atob", "globalThis.atob", "window.atob"].includes(decoder))
|
|
632
|
+
return Buffer.from(value, "base64").toString("latin1");
|
|
633
|
+
const [, encoding] = decoder.split(":");
|
|
634
|
+
return encoding ? Buffer.from(value, encoding).toString("utf8") : null;
|
|
635
|
+
}
|
|
636
|
+
function payloadFacts(decoded) {
|
|
637
|
+
return {
|
|
638
|
+
decodedBytes: Buffer.byteLength(decoded),
|
|
639
|
+
decodedHints: DECODED_HINTS.filter((hint) => decoded.includes(hint)),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
const OPAQUE = new Set([
|
|
643
|
+
"FunctionExpression",
|
|
644
|
+
"ArrowFunctionExpression",
|
|
645
|
+
"FunctionDeclaration",
|
|
646
|
+
"ClassExpression",
|
|
647
|
+
"ClassDeclaration",
|
|
648
|
+
]);
|
|
649
|
+
function codeArguments(name, node) {
|
|
650
|
+
const args = node.arguments;
|
|
651
|
+
if (name === "Function" || name === "globalThis.Function") return args;
|
|
652
|
+
if (
|
|
653
|
+
/^child_process\.(spawn|spawnSync|execFile|execFileSync|fork)$/.test(name)
|
|
654
|
+
)
|
|
655
|
+
return args.slice(0, 2);
|
|
656
|
+
return args.slice(0, 1);
|
|
657
|
+
}
|
|
658
|
+
function findDecoder(starts, aliases, bindings) {
|
|
659
|
+
const stack = starts.toReversed().map((item) => [item, 0, 0]);
|
|
660
|
+
const seen = new Set();
|
|
661
|
+
let visited = 0,
|
|
662
|
+
truncated = false;
|
|
663
|
+
while (stack.length && visited < OBFUSCATION_LIMITS.subtreeNodes) {
|
|
664
|
+
const [node, depth, hops] = stack.pop();
|
|
665
|
+
if (!node || seen.has(node) || OPAQUE.has(node.type)) continue;
|
|
666
|
+
seen.add(node);
|
|
667
|
+
visited++;
|
|
668
|
+
const decoder = decoderOf(node, aliases);
|
|
669
|
+
if (decoder) {
|
|
670
|
+
const candidate = literalArgument(node, bindings);
|
|
671
|
+
const payload =
|
|
672
|
+
stringLiteral(candidate) !== null &&
|
|
673
|
+
(isEncodedText(candidate.value) ||
|
|
674
|
+
decoder === "unescape" ||
|
|
675
|
+
decoder === "decodeURIComponent")
|
|
676
|
+
? candidate
|
|
677
|
+
: undefined;
|
|
678
|
+
const decoded = decodeLiteral(decoder, node, bindings);
|
|
679
|
+
return {
|
|
680
|
+
decoder: decoder.split(":")[0],
|
|
681
|
+
encoding: decoder.split(":")[1],
|
|
682
|
+
node,
|
|
683
|
+
payload,
|
|
684
|
+
...(decoded === null ? {} : payloadFacts(decoded)),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (node.type === "Identifier" && hops < OBFUSCATION_LIMITS.bindingHops) {
|
|
688
|
+
const init = bindings.get(node.name);
|
|
689
|
+
if (init) stack.push([init, 0, hops + 1]);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (depth >= OBFUSCATION_LIMITS.subtreeDepth) {
|
|
693
|
+
truncated ||= !["Literal", "Identifier", "TemplateElement"].includes(
|
|
694
|
+
node.type,
|
|
695
|
+
);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const children = [];
|
|
699
|
+
for (const [key, value] of Object.entries(node)) {
|
|
700
|
+
if (key === "callee" && node.type === "CallExpression") {
|
|
701
|
+
if (value.type === "MemberExpression") children.push(value.object);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (Array.isArray(value)) {
|
|
705
|
+
for (const item of value) if (item?.type) children.push(item);
|
|
706
|
+
} else if (value?.type) children.push(value);
|
|
707
|
+
}
|
|
708
|
+
for (const child of children.reverse())
|
|
709
|
+
stack.push([child, depth + 1, hops]);
|
|
710
|
+
}
|
|
711
|
+
return truncated || stack.length ? { capped: true } : null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function unwrapToString(node) {
|
|
715
|
+
return node?.type === "CallExpression" &&
|
|
716
|
+
node.callee.type === "MemberExpression" &&
|
|
717
|
+
!node.callee.computed &&
|
|
718
|
+
node.callee.property.name === "toString"
|
|
719
|
+
? node.callee.object
|
|
720
|
+
: node;
|
|
721
|
+
}
|
|
722
|
+
function decodeHelpers(all, aliases) {
|
|
723
|
+
for (const node of all) {
|
|
724
|
+
const [name, fn] =
|
|
725
|
+
node.type === "FunctionDeclaration" && node.id
|
|
726
|
+
? [node.id.name, node]
|
|
727
|
+
: node.type === "VariableDeclarator" &&
|
|
728
|
+
node.id.type === "Identifier" &&
|
|
729
|
+
FUNCTIONS.has(node.init?.type)
|
|
730
|
+
? [node.id.name, node.init]
|
|
731
|
+
: [];
|
|
732
|
+
const param = fn?.params[0];
|
|
733
|
+
if (param?.type !== "Identifier") continue;
|
|
734
|
+
const body =
|
|
735
|
+
fn.body.type === "BlockStatement"
|
|
736
|
+
? fn.body.body.length === 1 &&
|
|
737
|
+
fn.body.body[0].type === "ReturnStatement"
|
|
738
|
+
? fn.body.body[0].argument
|
|
739
|
+
: null
|
|
740
|
+
: fn.body;
|
|
741
|
+
const call = unwrapToString(body);
|
|
742
|
+
const decoder = call && decoderOf(call, aliases);
|
|
743
|
+
if (
|
|
744
|
+
!decoder ||
|
|
745
|
+
call.arguments[0]?.type !== "Identifier" ||
|
|
746
|
+
call.arguments[0].name !== param.name
|
|
747
|
+
)
|
|
748
|
+
continue;
|
|
749
|
+
const encoding = decoder.split(":")[1] || "base64";
|
|
750
|
+
aliases.set(name, `decoder-helper:${name}:${encoding}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
function moduleInstances(all, aliases) {
|
|
754
|
+
const names = new Set();
|
|
755
|
+
for (const node of all)
|
|
756
|
+
if (
|
|
757
|
+
node.type === "VariableDeclarator" &&
|
|
758
|
+
node.id.type === "Identifier" &&
|
|
759
|
+
node.init?.type === "NewExpression" &&
|
|
760
|
+
["module.constructor", "module"].includes(
|
|
761
|
+
qualified(node.init.callee, aliases),
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
names.add(node.id.name);
|
|
765
|
+
return names;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function obfuscationSignals(all) {
|
|
769
|
+
const aliases = moduleAliases(all);
|
|
770
|
+
decodeHelpers(all, aliases);
|
|
771
|
+
const instances = moduleInstances(all, aliases);
|
|
772
|
+
const requires = new Set(["require"]);
|
|
773
|
+
for (const node of all)
|
|
774
|
+
if (
|
|
775
|
+
node.type === "VariableDeclarator" &&
|
|
776
|
+
node.id.type === "Identifier" &&
|
|
777
|
+
node.init?.type === "Identifier" &&
|
|
778
|
+
node.init.name === "require"
|
|
779
|
+
)
|
|
780
|
+
requires.add(node.id.name);
|
|
781
|
+
const bindings = singleBindings(all);
|
|
782
|
+
const signals = [];
|
|
783
|
+
let sinks = 0,
|
|
784
|
+
capped = false;
|
|
785
|
+
for (const node of all) {
|
|
786
|
+
if (!["CallExpression", "NewExpression"].includes(node.type)) continue;
|
|
787
|
+
const callee = node.callee;
|
|
788
|
+
const instanceCall =
|
|
789
|
+
callee.type === "MemberExpression" &&
|
|
790
|
+
callee.object.type === "Identifier" &&
|
|
791
|
+
instances.has(callee.object.name);
|
|
792
|
+
const name = instanceCall
|
|
793
|
+
? callee.computed
|
|
794
|
+
? "Module#[computed]"
|
|
795
|
+
: `Module#${callee.property.name}`
|
|
796
|
+
: qualified(callee, aliases);
|
|
797
|
+
const module = requiredModule(node);
|
|
798
|
+
if (instanceCall && callee.computed) {
|
|
799
|
+
const found = findDecoder([callee.property], aliases, bindings);
|
|
800
|
+
if (found && !found.capped) {
|
|
801
|
+
if (++sinks > OBFUSCATION_LIMITS.sinksPerFile) {
|
|
802
|
+
capped = true;
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
signals.push({
|
|
806
|
+
ruleId: "behavior-encoded-dynamic-code",
|
|
807
|
+
priority: "HIGH",
|
|
808
|
+
node,
|
|
809
|
+
related: [found.node].filter((item) => item !== node),
|
|
810
|
+
detail: {
|
|
811
|
+
sink: name,
|
|
812
|
+
decoder: found.decoder,
|
|
813
|
+
decodedProperty: true,
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const dynamic = DYNAMIC_SINKS.has(name) || name === "Module#_compile";
|
|
820
|
+
const spawnsProcess = PROCESS_SINKS.has(name);
|
|
821
|
+
const encodedRequire =
|
|
822
|
+
node.callee.type === "Identifier" &&
|
|
823
|
+
requires.has(node.callee.name) &&
|
|
824
|
+
module === undefined &&
|
|
825
|
+
node.arguments.length === 1;
|
|
826
|
+
if (!dynamic && !spawnsProcess && !encodedRequire) continue;
|
|
827
|
+
if (++sinks > OBFUSCATION_LIMITS.sinksPerFile) {
|
|
828
|
+
capped = true;
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
if (spawnsProcess) {
|
|
832
|
+
const command = stringLiteral(node.arguments[0]);
|
|
833
|
+
const remote =
|
|
834
|
+
command !== null &&
|
|
835
|
+
scriptSignals(command).signals?.find(
|
|
836
|
+
(item) => item.ruleId === "behavior-install-remote-shell",
|
|
837
|
+
);
|
|
838
|
+
if (remote) {
|
|
839
|
+
signals.push({
|
|
840
|
+
ruleId: "behavior-process-remote-shell",
|
|
841
|
+
priority: "HIGH",
|
|
842
|
+
node,
|
|
843
|
+
related: [],
|
|
844
|
+
detail: {
|
|
845
|
+
sink: name,
|
|
846
|
+
tools: remote.tools,
|
|
847
|
+
interpreter: remote.interpreter,
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const found = node.arguments.length
|
|
854
|
+
? findDecoder(
|
|
855
|
+
codeArguments(encodedRequire ? "require" : name, node),
|
|
856
|
+
aliases,
|
|
857
|
+
bindings,
|
|
858
|
+
)
|
|
859
|
+
: null;
|
|
860
|
+
if (found?.capped) {
|
|
861
|
+
capped = true;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (!found) continue;
|
|
865
|
+
const { node: decoderNode, payload, decoder, encoding, ...facts } = found;
|
|
866
|
+
const detail = {
|
|
867
|
+
sink: encodedRequire ? "require" : name,
|
|
868
|
+
decoder,
|
|
869
|
+
...(encoding ? { encoding } : {}),
|
|
870
|
+
...facts,
|
|
871
|
+
};
|
|
872
|
+
const ruleId = dynamic
|
|
873
|
+
? "behavior-encoded-dynamic-code"
|
|
874
|
+
: spawnsProcess
|
|
875
|
+
? "behavior-encoded-process-execution"
|
|
876
|
+
: "behavior-encoded-module-load";
|
|
877
|
+
let priority = "HIGH";
|
|
878
|
+
if (encodedRequire) {
|
|
879
|
+
const decoded = decodeLiteral(
|
|
880
|
+
decoderOf(decoderNode, aliases),
|
|
881
|
+
decoderNode,
|
|
882
|
+
bindings,
|
|
883
|
+
);
|
|
884
|
+
if (decoded === null) continue;
|
|
885
|
+
const printable = decoded.length <= 64 && /^[\x20-\x7e]+$/.test(decoded);
|
|
886
|
+
if (printable) detail.decodedModule = decoded.replace(/^node:/, "");
|
|
887
|
+
priority = SENSITIVE_MODULES.has(detail.decodedModule)
|
|
888
|
+
? "HIGH"
|
|
889
|
+
: "review";
|
|
890
|
+
}
|
|
891
|
+
signals.push({
|
|
892
|
+
ruleId,
|
|
893
|
+
priority,
|
|
894
|
+
node,
|
|
895
|
+
related: [decoderNode, ...(payload ? [payload] : [])].filter(
|
|
896
|
+
(item) => item !== node,
|
|
897
|
+
),
|
|
898
|
+
detail,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
return { signals, capped };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export const DESTRUCTIVE_LIMITS = Object.freeze({
|
|
905
|
+
functions: 512,
|
|
906
|
+
bodyNodes: 4096,
|
|
907
|
+
callSites: 64,
|
|
908
|
+
decodedPerName: 16,
|
|
909
|
+
});
|
|
910
|
+
const FS_MODULES = new Set(["fs", "fs/promises", "graceful-fs", "fs-extra"]);
|
|
911
|
+
const LISTING = new Set(["readdirSync", "readdir", "opendirSync", "opendir"]);
|
|
912
|
+
const MUTATING = new Set([
|
|
913
|
+
"writeFile",
|
|
914
|
+
"writeFileSync",
|
|
915
|
+
"appendFile",
|
|
916
|
+
"appendFileSync",
|
|
917
|
+
"unlink",
|
|
918
|
+
"unlinkSync",
|
|
919
|
+
"rm",
|
|
920
|
+
"rmSync",
|
|
921
|
+
"rmdir",
|
|
922
|
+
"rmdirSync",
|
|
923
|
+
"truncate",
|
|
924
|
+
"truncateSync",
|
|
925
|
+
"createWriteStream",
|
|
926
|
+
]);
|
|
927
|
+
const ROOT_TARGETS = new Set([
|
|
928
|
+
"/",
|
|
929
|
+
"\\",
|
|
930
|
+
"..",
|
|
931
|
+
"../",
|
|
932
|
+
"../..",
|
|
933
|
+
"../../",
|
|
934
|
+
"~",
|
|
935
|
+
"~/",
|
|
936
|
+
"C:\\",
|
|
937
|
+
"C:/",
|
|
938
|
+
]);
|
|
939
|
+
const DECODED_ROOT_TARGETS = new Set([...ROOT_TARGETS, ".", "./"]);
|
|
940
|
+
const FUNCTIONS = new Set([
|
|
941
|
+
"FunctionDeclaration",
|
|
942
|
+
"FunctionExpression",
|
|
943
|
+
"ArrowFunctionExpression",
|
|
944
|
+
]);
|
|
945
|
+
function fsOperation(node, aliases) {
|
|
946
|
+
if (node.type !== "CallExpression") return null;
|
|
947
|
+
const name = qualified(node.callee, aliases);
|
|
948
|
+
const parts = name.split(".");
|
|
949
|
+
return FS_MODULES.has(parts[0]) ? parts.at(-1) : null;
|
|
950
|
+
}
|
|
951
|
+
function namedFunctions(all) {
|
|
952
|
+
const result = [];
|
|
953
|
+
for (const node of all) {
|
|
954
|
+
if (node.type === "FunctionDeclaration" && node.id)
|
|
955
|
+
result.push([node.id.name, node]);
|
|
956
|
+
else if (
|
|
957
|
+
node.type === "VariableDeclarator" &&
|
|
958
|
+
node.id.type === "Identifier" &&
|
|
959
|
+
FUNCTIONS.has(node.init?.type)
|
|
960
|
+
)
|
|
961
|
+
result.push([node.id.name, node.init]);
|
|
962
|
+
}
|
|
963
|
+
return result;
|
|
964
|
+
}
|
|
965
|
+
function walkerFacts(name, fn, aliases) {
|
|
966
|
+
const facts = {};
|
|
967
|
+
const stack = [fn.body];
|
|
968
|
+
let visited = 0;
|
|
969
|
+
while (stack.length) {
|
|
970
|
+
if (++visited > DESTRUCTIVE_LIMITS.bodyNodes) return { capped: true };
|
|
971
|
+
const node = stack.pop();
|
|
972
|
+
if (node.type === "CallExpression") {
|
|
973
|
+
const operation = fsOperation(node, aliases);
|
|
974
|
+
if (LISTING.has(operation)) facts.listing ??= node;
|
|
975
|
+
if (MUTATING.has(operation)) facts.mutation ??= [node, operation];
|
|
976
|
+
if (node.callee.type === "Identifier" && node.callee.name === name)
|
|
977
|
+
facts.recursion ??= node;
|
|
978
|
+
}
|
|
979
|
+
for (const value of Object.values(node))
|
|
980
|
+
if (Array.isArray(value)) {
|
|
981
|
+
for (const item of value) if (item?.type) stack.push(item);
|
|
982
|
+
} else if (value?.type) stack.push(value);
|
|
983
|
+
}
|
|
984
|
+
return facts;
|
|
985
|
+
}
|
|
986
|
+
function decodedValues(all, aliases) {
|
|
987
|
+
const values = new Map();
|
|
988
|
+
for (const node of all) {
|
|
989
|
+
if (node.type !== "VariableDeclarator" || node.id.type !== "Identifier")
|
|
990
|
+
continue;
|
|
991
|
+
let init = node.init;
|
|
992
|
+
if (
|
|
993
|
+
init?.type === "CallExpression" &&
|
|
994
|
+
init.callee.type === "MemberExpression" &&
|
|
995
|
+
!init.callee.computed &&
|
|
996
|
+
init.callee.property.name === "toString"
|
|
997
|
+
)
|
|
998
|
+
init = init.callee.object;
|
|
999
|
+
const decoder = init && decoderOf(init, aliases);
|
|
1000
|
+
const decoded = decoder && decodeLiteral(decoder, init);
|
|
1001
|
+
if (typeof decoded !== "string") continue;
|
|
1002
|
+
const list = values.get(node.id.name) ?? [];
|
|
1003
|
+
if (list.length < DESTRUCTIVE_LIMITS.decodedPerName) list.push(decoded);
|
|
1004
|
+
values.set(node.id.name, list);
|
|
1005
|
+
}
|
|
1006
|
+
return values;
|
|
1007
|
+
}
|
|
1008
|
+
function targetOf(arg, aliases, decoded) {
|
|
1009
|
+
const literal = stringLiteral(arg);
|
|
1010
|
+
if (literal !== null)
|
|
1011
|
+
return ROOT_TARGETS.has(literal)
|
|
1012
|
+
? { via: "literal", targets: [literal] }
|
|
1013
|
+
: null;
|
|
1014
|
+
if (
|
|
1015
|
+
arg?.type === "CallExpression" &&
|
|
1016
|
+
qualified(arg.callee, aliases).endsWith("homedir")
|
|
1017
|
+
)
|
|
1018
|
+
return { via: "home-directory", targets: ["~"] };
|
|
1019
|
+
let base = arg;
|
|
1020
|
+
if (
|
|
1021
|
+
base?.type === "CallExpression" &&
|
|
1022
|
+
base.callee.type === "MemberExpression" &&
|
|
1023
|
+
!base.callee.computed &&
|
|
1024
|
+
base.callee.property.name === "toString"
|
|
1025
|
+
)
|
|
1026
|
+
base = base.callee.object;
|
|
1027
|
+
const decoder = base && decoderOf(base, aliases);
|
|
1028
|
+
const values = decoder
|
|
1029
|
+
? [decodeLiteral(decoder, base)].filter((v) => typeof v === "string")
|
|
1030
|
+
: base?.type === "Identifier"
|
|
1031
|
+
? decoded.get(base.name) || []
|
|
1032
|
+
: [];
|
|
1033
|
+
const roots = values.filter((value) => DECODED_ROOT_TARGETS.has(value));
|
|
1034
|
+
return roots.length ? { via: "decoded", targets: [...new Set(roots)] } : null;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export function destructiveSignals(all) {
|
|
1038
|
+
const aliases = new Map([
|
|
1039
|
+
...moduleAliases(all, FS_MODULES),
|
|
1040
|
+
...moduleAliases(all, new Set(["os"])),
|
|
1041
|
+
]);
|
|
1042
|
+
const functions = namedFunctions(all);
|
|
1043
|
+
if (functions.length > DESTRUCTIVE_LIMITS.functions)
|
|
1044
|
+
return { signals: [], capped: true };
|
|
1045
|
+
const walkers = new Map();
|
|
1046
|
+
let capped = false;
|
|
1047
|
+
for (const [name, fn] of functions) {
|
|
1048
|
+
const facts = walkerFacts(name, fn, aliases);
|
|
1049
|
+
if (facts.capped) capped = true;
|
|
1050
|
+
else if (facts.listing && facts.mutation && facts.recursion)
|
|
1051
|
+
walkers.set(name, facts);
|
|
1052
|
+
}
|
|
1053
|
+
const signals = [];
|
|
1054
|
+
if (!walkers.size) return { signals, capped };
|
|
1055
|
+
const decoded = decodedValues(all, moduleAliases(all));
|
|
1056
|
+
const reported = new Set();
|
|
1057
|
+
let sites = 0;
|
|
1058
|
+
for (const node of all) {
|
|
1059
|
+
if (
|
|
1060
|
+
node.type !== "CallExpression" ||
|
|
1061
|
+
node.callee.type !== "Identifier" ||
|
|
1062
|
+
!walkers.has(node.callee.name) ||
|
|
1063
|
+
reported.has(node.callee.name)
|
|
1064
|
+
)
|
|
1065
|
+
continue;
|
|
1066
|
+
if (++sites > DESTRUCTIVE_LIMITS.callSites) {
|
|
1067
|
+
capped = true;
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
const target = targetOf(node.arguments[0], aliases, decoded);
|
|
1071
|
+
if (!target) continue;
|
|
1072
|
+
const facts = walkers.get(node.callee.name);
|
|
1073
|
+
reported.add(node.callee.name);
|
|
1074
|
+
signals.push({
|
|
1075
|
+
ruleId: "behavior-recursive-overwrite",
|
|
1076
|
+
priority: "HIGH",
|
|
1077
|
+
node,
|
|
1078
|
+
related: [facts.mutation[0], facts.listing],
|
|
1079
|
+
detail: {
|
|
1080
|
+
walker: node.callee.name,
|
|
1081
|
+
operation: facts.mutation[1],
|
|
1082
|
+
via: target.via,
|
|
1083
|
+
targets: target.targets,
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return { signals, capped };
|
|
1088
|
+
}
|