@vola-trebla/croak 0.0.0-stage → 0.1.0

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -2
  3. package/dist/cli.js +373 -0
  4. package/dist/crypto/der.js +136 -0
  5. package/dist/crypto/fields.js +60 -0
  6. package/dist/crypto/registry-signature.js +88 -0
  7. package/dist/crypto/rekor.js +70 -0
  8. package/dist/crypto/sigstore.js +121 -0
  9. package/dist/detectors/git-config.js +137 -0
  10. package/dist/detectors/hidden-code.js +58 -0
  11. package/dist/detectors/ide-autorun.js +136 -0
  12. package/dist/detectors/index.js +20 -0
  13. package/dist/detectors/lifecycle-script.js +337 -0
  14. package/dist/detectors/node-modules-present.js +32 -0
  15. package/dist/detectors/obfuscation.js +124 -0
  16. package/dist/detectors/unscanned.js +41 -0
  17. package/dist/hook/hook.js +391 -0
  18. package/dist/hook/install-commands.js +927 -0
  19. package/dist/install/content-cache.js +94 -0
  20. package/dist/install/content.js +42 -0
  21. package/dist/install/detectors.js +79 -0
  22. package/dist/install/installed.js +99 -0
  23. package/dist/install/lockfile.js +72 -0
  24. package/dist/install/metadata.js +159 -0
  25. package/dist/install/plan.js +31 -0
  26. package/dist/install/pnpm-builds.js +111 -0
  27. package/dist/install/pnpm-lockfile.js +102 -0
  28. package/dist/install/popular.js +177 -0
  29. package/dist/install/project.js +223 -0
  30. package/dist/install/provenance.js +77 -0
  31. package/dist/install/registry.js +274 -0
  32. package/dist/install/report.js +26 -0
  33. package/dist/install/sanitize.js +88 -0
  34. package/dist/install/scan.js +302 -0
  35. package/dist/install/signature.js +62 -0
  36. package/dist/install/tar.js +178 -0
  37. package/dist/install/workspace.js +31 -0
  38. package/dist/install/yaml.js +282 -0
  39. package/dist/llm/client.js +22 -0
  40. package/dist/llm/cost.js +26 -0
  41. package/dist/llm/diff.js +114 -0
  42. package/dist/llm/escalate.js +14 -0
  43. package/dist/llm/plan-step.js +91 -0
  44. package/dist/llm/select.js +21 -0
  45. package/dist/llm/triage.js +111 -0
  46. package/dist/repo-report.js +95 -0
  47. package/dist/repo-snapshot.js +157 -0
  48. package/dist/scan.js +48 -0
  49. package/dist/signals/code.js +136 -0
  50. package/dist/signals/git-config.js +58 -0
  51. package/dist/signals/hidden.js +77 -0
  52. package/dist/signals/jsonc.js +100 -0
  53. package/dist/signals/obfuscation.js +98 -0
  54. package/dist/signals/shell.js +717 -0
  55. package/dist/signals/text.js +2 -0
  56. package/dist/ui.js +12 -0
  57. package/dist/verdict.js +20 -0
  58. package/package.json +71 -4
  59. package/scripts/croak-hook.sh +59 -0
  60. package/scripts/croak-install.sh +262 -0
@@ -0,0 +1,136 @@
1
+ import { posix } from "node:path";
2
+ import { JsoncError, parseJsonc } from "../signals/jsonc.js";
3
+ import { stripBom } from "../signals/text.js";
4
+ import { assessor } from "./lifecycle-script.js";
5
+ import { isInNodeModules } from "./node-modules-present.js";
6
+ const RULE = "repo/ide-autorun";
7
+ const TASKS_FILE = ".vscode/tasks.json";
8
+ const MAX_SHOWN = 80;
9
+ function isRecord(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ const asString = (value) => typeof value === "string" ? value : undefined;
13
+ const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
14
+ /** Lines (1-based) that mention folderOpen, in order: one per autorun task. */
15
+ function folderOpenLines(text) {
16
+ return text
17
+ .split("\n")
18
+ .flatMap((line, index) => (line.includes("folderOpen") ? [index + 1] : []));
19
+ }
20
+ /** The npm script a task runs, read from the package.json next to the .vscode folder. */
21
+ /** The package.json next to the .vscode folder: its directory and scripts. */
22
+ function manifestFor(repo, tasksPath) {
23
+ const folder = posix.dirname(posix.dirname(tasksPath));
24
+ const dir = folder === "." ? "" : folder;
25
+ const text = repo.read(dir === "" ? "package.json" : `${dir}/package.json`);
26
+ if (text === undefined)
27
+ return { dir, scripts: {} };
28
+ try {
29
+ const manifest = JSON.parse(stripBom(text));
30
+ return {
31
+ dir,
32
+ scripts: isRecord(manifest) && isRecord(manifest.scripts)
33
+ ? manifest.scripts
34
+ : {},
35
+ };
36
+ }
37
+ catch {
38
+ // An unreadable package.json is reported by repo/lifecycle-script.
39
+ return { dir, scripts: {} };
40
+ }
41
+ }
42
+ function taskFinding(repo, path, task, line, trigger) {
43
+ const label = asString(task.label) ?? "(unnamed task)";
44
+ const presentation = isRecord(task.presentation) ? task.presentation : {};
45
+ const hidden = presentation.reveal === "never" ? ", with its terminal hidden" : "";
46
+ const at = line === undefined ? { file: path } : { file: path, line };
47
+ if (task.type === "npm") {
48
+ // Same analysis as install scripts: the script, the files it runs, and
49
+ // the scripts it calls through npm run.
50
+ const script = asString(task.script) ?? "";
51
+ const { dir, scripts } = manifestFor(repo, path);
52
+ const command = asString(scripts[script]);
53
+ const assessment = command === undefined ? undefined : assessor(repo, dir, scripts)(command);
54
+ const shown = command === undefined
55
+ ? "not found in package.json"
56
+ : `\`${truncate(command)}\``;
57
+ const reasons = assessment !== undefined && assessment.reasons.length > 0
58
+ ? `: ${assessment.reasons.join("; ")}`
59
+ : "";
60
+ return {
61
+ ruleId: RULE,
62
+ severity: assessment?.block === true ? "block" : "warn",
63
+ ...at,
64
+ message: `task "${label}" runs npm script "${script}" (${shown}) ${trigger}${hidden}${reasons}`,
65
+ };
66
+ }
67
+ // shell, process, or no type: VS Code runs the command directly.
68
+ const args = Array.isArray(task.args)
69
+ ? task.args.filter((a) => typeof a === "string")
70
+ : [];
71
+ const command = [asString(task.command) ?? "(command)", ...args].join(" ");
72
+ return {
73
+ ruleId: RULE,
74
+ severity: "block",
75
+ ...at,
76
+ message: `task "${label}" runs \`${truncate(command)}\` automatically ${trigger}${hidden}`,
77
+ };
78
+ }
79
+ const dependsOnLabels = (task) => {
80
+ const deps = task.dependsOn;
81
+ if (typeof deps === "string")
82
+ return [deps];
83
+ return Array.isArray(deps)
84
+ ? deps.filter((d) => typeof d === "string")
85
+ : [];
86
+ };
87
+ function tasksFindings(repo, path) {
88
+ const text = repo.read(path);
89
+ if (text === undefined)
90
+ return [];
91
+ let config;
92
+ try {
93
+ config = parseJsonc(stripBom(text));
94
+ }
95
+ catch (error) {
96
+ if (!(error instanceof JsoncError))
97
+ throw error;
98
+ return [
99
+ {
100
+ ruleId: RULE,
101
+ severity: "warn",
102
+ file: path,
103
+ message: `tasks.json could not be parsed (${error.message}); VS Code may still run its tasks`,
104
+ },
105
+ ];
106
+ }
107
+ const tasks = isRecord(config) && Array.isArray(config.tasks)
108
+ ? config.tasks.filter(isRecord)
109
+ : [];
110
+ const byLabel = new Map(tasks.flatMap((task) => typeof task.label === "string" ? [[task.label, task]] : []));
111
+ const autorun = tasks.filter((task) => isRecord(task.runOptions) && task.runOptions.runOn === "folderOpen");
112
+ const lines = folderOpenLines(text);
113
+ const findings = autorun.map((task, index) => taskFinding(repo, path, task, lines[index], "when the folder is opened"));
114
+ // Tasks an autorun task depends on run first, just as automatically.
115
+ const seen = new Set(autorun);
116
+ const queue = autorun.flatMap((task) => dependsOnLabels(task).map((dep) => ({
117
+ dep,
118
+ parent: asString(task.label) ?? "",
119
+ })));
120
+ while (queue.length > 0) {
121
+ const next = queue.shift();
122
+ const task = next === undefined ? undefined : byLabel.get(next.dep);
123
+ if (next === undefined || task === undefined || seen.has(task))
124
+ continue;
125
+ seen.add(task);
126
+ findings.push(taskFinding(repo, path, task, undefined, `when the folder is opened (folder-open task "${next.parent}" depends on it)`));
127
+ queue.push(...dependsOnLabels(task).map((dep) => ({ dep, parent: next.dep })));
128
+ }
129
+ return findings;
130
+ }
131
+ /** VS Code tasks that run by themselves when the folder is opened. */
132
+ export const ideAutorun = (repo) => repo.entries
133
+ .filter((e) => e.kind === "file" &&
134
+ (e.path === TASKS_FILE || e.path.endsWith(`/${TASKS_FILE}`)) &&
135
+ !isInNodeModules(e.path))
136
+ .flatMap((e) => tasksFindings(repo, e.path));
@@ -0,0 +1,20 @@
1
+ import { gitConfig } from "./git-config.js";
2
+ import { hiddenCodeDetector } from "./hidden-code.js";
3
+ import { ideAutorun } from "./ide-autorun.js";
4
+ import { lifecycleScript } from "./lifecycle-script.js";
5
+ import { nodeModulesPresent } from "./node-modules-present.js";
6
+ import { obfuscation } from "./obfuscation.js";
7
+ import { unscanned } from "./unscanned.js";
8
+ /** Registered repo detectors. `unscanned` is the safety floor and always runs. */
9
+ export const repoDetectors = [
10
+ unscanned,
11
+ nodeModulesPresent,
12
+ lifecycleScript,
13
+ ideAutorun,
14
+ hiddenCodeDetector,
15
+ obfuscation,
16
+ gitConfig,
17
+ ];
18
+ export function scanRepo(repo, detectors = repoDetectors) {
19
+ return detectors.flatMap((detect) => detect(repo));
20
+ }
@@ -0,0 +1,337 @@
1
+ import { posix } from "node:path";
2
+ import { codeSignals, describeSignals, isSuspicious, } from "../signals/code.js";
3
+ import { analyzeShell } from "../signals/shell.js";
4
+ import { stripBom } from "../signals/text.js";
5
+ import { isInNodeModules } from "./node-modules-present.js";
6
+ const RULE = "repo/lifecycle-script";
7
+ /** Every script npm runs during `npm install` in the project itself. */
8
+ export const INSTALL_SCRIPTS = [
9
+ "preinstall",
10
+ "install",
11
+ "postinstall",
12
+ "prepublish",
13
+ "preprepare",
14
+ "prepare",
15
+ "postprepare",
16
+ // runs after node_modules changes (npm docs)
17
+ "dependencies",
18
+ ];
19
+ /** The only scripts npm runs for a package installed from the registry. */
20
+ export const DEPENDENCY_INSTALL_SCRIPTS = [
21
+ "preinstall",
22
+ "install",
23
+ "postinstall",
24
+ ];
25
+ /** node_modules/<name>/package.json or node_modules/@scope/<name>/package.json, at any depth. */
26
+ const BUNDLED_MANIFEST = /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/@][^/]*\/package\.json$/;
27
+ /**
28
+ * Tools that run no project code: their install-time use can be an info note
29
+ * (D19). Everything they do is decided by the tool, not by files in the repo.
30
+ */
31
+ const INERT_TOOLS = new Set([
32
+ "husky",
33
+ "simple-git-hooks",
34
+ "lefthook",
35
+ "only-allow",
36
+ "opencollective-postinstall",
37
+ "playwright",
38
+ "tsc",
39
+ "esbuild",
40
+ "swc",
41
+ "echo",
42
+ "true",
43
+ "exit",
44
+ "printf",
45
+ ]);
46
+ /**
47
+ * Recognized install-time tools that execute code from the repo — a JS/TS
48
+ * config, workspace tasks, a generator, or patches. Recognizing one adds a
49
+ * note; the script stays a warning.
50
+ */
51
+ const EXECUTING_TOOLS = new Set([
52
+ "prisma",
53
+ "vite",
54
+ "webpack",
55
+ "rollup",
56
+ "tsup",
57
+ "unbuild",
58
+ "babel",
59
+ "nuxt",
60
+ "nuxi",
61
+ "svelte-kit",
62
+ "astro",
63
+ "ngcc",
64
+ "turbo",
65
+ "nx",
66
+ "lerna",
67
+ "patch-package",
68
+ "rimraf",
69
+ ]);
70
+ const KNOWN_TOOLS = new Set([...INERT_TOOLS, ...EXECUTING_TOOLS]);
71
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
72
+ const MAX_SCRIPT_DEPTH = 5;
73
+ const SHELL_DECODE = /\bbase64\s+(?:-d|-D|--decode)\b|\bopenssl\s+(?:enc|base64)\b[^|;&]*\s-d\b/;
74
+ const MAX_SHOWN = 80;
75
+ function isRecord(value) {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+ const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
79
+ /** 1-based line of `"name":` inside the manifest's "scripts" object, if found. */
80
+ function scriptLine(manifest, name) {
81
+ const lines = manifest.split("\n");
82
+ const scripts = lines.findIndex((line) => /"scripts"\s*:/.test(line));
83
+ const key = `"${name}"`;
84
+ const index = lines.findIndex((line, i) => i >= Math.max(scripts, 0) &&
85
+ line.includes(key) &&
86
+ /"\s*:/.test(line.slice(line.indexOf(key))));
87
+ return index === -1 ? undefined : index + 1;
88
+ }
89
+ /**
90
+ * Decides whether install-time commands are routine (D19): every command is an
91
+ * inert tool or a package-manager call that runs a script which is itself
92
+ * routine — including npm's pre/post hooks for that script — and nothing else
93
+ * is present: no network, local files, inline code, code signals, environment
94
+ * assignments, redirects, wrappers, or workspace-wide runs. Results are
95
+ * memoized per script name, so a layered script graph costs linear time.
96
+ */
97
+ function routineChecker(scripts) {
98
+ const memo = new Map();
99
+ const scriptIsRoutine = (name, depth) => {
100
+ const known = memo.get(name);
101
+ if (known !== undefined)
102
+ return known;
103
+ memo.set(name, false); // a cycle back here is not routine
104
+ const body = scripts[name];
105
+ const result = typeof body === "string" &&
106
+ commandIsRoutine(body, depth + 1) &&
107
+ [`pre${name}`, `post${name}`].every((hook) => !(hook in scripts) || scriptIsRoutine(hook, depth + 1));
108
+ memo.set(name, result);
109
+ return result;
110
+ };
111
+ const commandIsRoutine = (command, depth) => {
112
+ if (depth > MAX_SCRIPT_DEPTH)
113
+ return false;
114
+ const facts = analyzeShell(command);
115
+ if (facts.unparsable ||
116
+ facts.downloadExecute ||
117
+ facts.network ||
118
+ facts.localFiles.length > 0 ||
119
+ facts.inlineCode.length > 0 ||
120
+ facts.modifiers.length > 0 ||
121
+ codeSignals(command).length > 0 ||
122
+ facts.commands.length === 0) {
123
+ return false;
124
+ }
125
+ // Every package-manager call must run a script: `npm install x` does not.
126
+ const pmCalls = facts.commands.filter((name) => PACKAGE_MANAGERS.has(name)).length;
127
+ if (pmCalls !== facts.runScripts.length)
128
+ return false;
129
+ if (!facts.commands.every((name) => INERT_TOOLS.has(name) || PACKAGE_MANAGERS.has(name)))
130
+ return false;
131
+ return facts.runScripts.every((name) => scriptIsRoutine(name, depth));
132
+ };
133
+ return (command) => commandIsRoutine(command, 0);
134
+ }
135
+ /**
136
+ * What a script's command does, including the files and inline code it runs
137
+ * and the package.json scripts it calls through `npm run` (with npm's pre/post
138
+ * hooks), recursively. Signals are combined across the whole call graph, so
139
+ * an attack split across two scripts still blocks. Memoized per script name.
140
+ */
141
+ export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
142
+ const memo = new Map();
143
+ const empty = {
144
+ block: false,
145
+ reasons: [],
146
+ knownTools: [],
147
+ signals: new Set(),
148
+ };
149
+ const assessScript = (name, depth) => {
150
+ const known = memo.get(name);
151
+ if (known !== undefined)
152
+ return known;
153
+ memo.set(name, empty); // break cycles
154
+ const body = scripts[name];
155
+ const result = typeof body !== "string"
156
+ ? {
157
+ ...empty,
158
+ reasons: [`calls script "${name}", which is not in package.json`],
159
+ }
160
+ : assessCommand(body, depth + 1);
161
+ memo.set(name, result);
162
+ return result;
163
+ };
164
+ const assessCommand = (command, depth) => {
165
+ if (depth > MAX_SCRIPT_DEPTH)
166
+ return { ...empty, reasons: ["scripts are nested too deeply to follow"] };
167
+ const facts = analyzeShell(command);
168
+ if (facts.unparsable) {
169
+ // Fail closed: unparsable text that names a download tool could be
170
+ // hiding curl … | sh behind a trick the parser does not understand.
171
+ return facts.mentionsDownloader
172
+ ? {
173
+ ...empty,
174
+ block: true,
175
+ reasons: [
176
+ "could not be parsed and mentions a download tool (failing closed)",
177
+ ],
178
+ }
179
+ : {
180
+ ...empty,
181
+ reasons: [
182
+ "the command could not be parsed, so croak cannot tell what it runs",
183
+ ],
184
+ };
185
+ }
186
+ const reasons = [];
187
+ const signals = new Set();
188
+ const knownTools = new Set(facts.commands.filter((name) => KNOWN_TOOLS.has(name)));
189
+ let nestedBlock = false;
190
+ const absorb = (hits, where) => {
191
+ if (hits.length === 0)
192
+ return;
193
+ for (const hit of hits)
194
+ signals.add(hit.signal);
195
+ reasons.push(describeSignals(hits, where));
196
+ };
197
+ if (facts.downloadExecute)
198
+ reasons.push("downloads and executes code");
199
+ if (facts.network) {
200
+ signals.add("network");
201
+ reasons.push("uses the network");
202
+ }
203
+ if (SHELL_DECODE.test(command))
204
+ signals.add("decode");
205
+ absorb(codeSignals(command), "the command");
206
+ for (const code of facts.inlineCode)
207
+ absorb(codeSignals(code), "inline code");
208
+ for (const file of facts.localFiles) {
209
+ const path = posix.normalize(posix.join(dir, file));
210
+ if (path === ".." || path.startsWith("../")) {
211
+ reasons.push(`runs ${file}, a path outside the repo`);
212
+ continue;
213
+ }
214
+ const entry = repo.entries.find((e) => e.path === path);
215
+ if (entry === undefined)
216
+ reasons.push(`runs ${path}, which is not in the repo`);
217
+ else if (entry.kind === "oversized")
218
+ reasons.push(`runs ${path}, too large to scan`);
219
+ else if (entry.kind === "symlink")
220
+ reasons.push(`runs ${path}, a symlink croak does not follow`);
221
+ else
222
+ absorb(codeSignals(repo.read(path) ?? ""), path);
223
+ }
224
+ for (const name of facts.runScripts) {
225
+ for (const script of [`pre${name}`, name, `post${name}`]) {
226
+ if (script !== name && !(script in scripts))
227
+ continue;
228
+ const sub = assessScript(script, depth);
229
+ nestedBlock ||= sub.block;
230
+ for (const signal of sub.signals)
231
+ signals.add(signal);
232
+ for (const tool of sub.knownTools)
233
+ knownTools.add(tool);
234
+ if (sub.reasons.length > 0)
235
+ reasons.push(`via script "${script}": ${sub.reasons.join("; ")}`);
236
+ }
237
+ }
238
+ return {
239
+ block: facts.downloadExecute || nestedBlock || suspicious(signals),
240
+ reasons,
241
+ knownTools: [...knownTools],
242
+ signals,
243
+ };
244
+ };
245
+ return (command) => assessCommand(command, 0);
246
+ }
247
+ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
248
+ const dir = posix.dirname(path) === "." ? "" : posix.dirname(path);
249
+ const text = repo.read(path);
250
+ if (text === undefined)
251
+ return [];
252
+ let manifest;
253
+ try {
254
+ manifest = JSON.parse(stripBom(text));
255
+ }
256
+ catch {
257
+ // Not swallowed: an unreadable manifest is reported, the scan continues.
258
+ return [
259
+ {
260
+ ruleId: RULE,
261
+ severity: "warn",
262
+ file: path,
263
+ message: "package.json is not valid JSON; croak cannot see its install scripts",
264
+ },
265
+ ];
266
+ }
267
+ const scripts = isRecord(manifest) && isRecord(manifest.scripts) ? manifest.scripts : {};
268
+ const findings = [];
269
+ const isRoutine = routineChecker(scripts);
270
+ const assess = assessor(repo, dir, scripts, suspicious);
271
+ for (const name of installScripts) {
272
+ const command = scripts[name];
273
+ if (typeof command !== "string")
274
+ continue;
275
+ const { block, reasons, knownTools } = assess(command);
276
+ const line = scriptLine(text, name);
277
+ const at = line === undefined ? { file: path } : { file: path, line };
278
+ if (!block && isRoutine(command)) {
279
+ findings.push({
280
+ ruleId: RULE,
281
+ severity: "info",
282
+ ...at,
283
+ message: `${name} runs \`${truncate(command)}\` on install: known tools only, nothing suspicious`,
284
+ });
285
+ continue;
286
+ }
287
+ findings.push({
288
+ ruleId: RULE,
289
+ severity: block ? "block" : "warn",
290
+ ...at,
291
+ message: `${name} runs \`${truncate(command)}\` on install${reasons.length > 0 ? `: ${reasons.join("; ")}` : ""}`,
292
+ });
293
+ for (const tool of knownTools) {
294
+ findings.push({
295
+ ruleId: RULE,
296
+ severity: "info",
297
+ ...at,
298
+ message: `${name} uses ${tool}, a known install-time tool`,
299
+ });
300
+ }
301
+ }
302
+ // npm runs `node-gyp rebuild` for a package with binding.gyp and no install script of its own.
303
+ const gyp = posix.join(dir, "binding.gyp");
304
+ if (!("install" in scripts) &&
305
+ !("preinstall" in scripts) &&
306
+ repo.entries.some((e) => e.path === gyp)) {
307
+ findings.push({
308
+ ruleId: RULE,
309
+ severity: "warn",
310
+ file: gyp,
311
+ message: "binding.gyp makes npm run `node-gyp rebuild` on install; gyp files can run arbitrary build actions",
312
+ });
313
+ }
314
+ return findings;
315
+ }
316
+ /**
317
+ * Install-time scripts in every package.json outside node_modules (workspaces
318
+ * run theirs too). The policy decides which code-signal combinations block and
319
+ * which scripts count; package contents use one that tolerates binary
320
+ * installers and follows npm's rules for registry dependencies.
321
+ */
322
+ export function lifecycleScriptWith(policy) {
323
+ return (repo) => repo.entries
324
+ .filter((e) => e.kind === "file" &&
325
+ (policy.manifests === "root"
326
+ ? e.path === "package.json"
327
+ : policy.manifests === "package"
328
+ ? e.path === "package.json" || BUNDLED_MANIFEST.test(e.path)
329
+ : posix.basename(e.path) === "package.json" &&
330
+ !isInNodeModules(e.path)))
331
+ .flatMap((e) => manifestFindings(repo, e.path, policy));
332
+ }
333
+ export const lifecycleScript = lifecycleScriptWith({
334
+ suspicious: isSuspicious,
335
+ scripts: INSTALL_SCRIPTS,
336
+ manifests: "repo",
337
+ });
@@ -0,0 +1,32 @@
1
+ const SEGMENT = "node_modules";
2
+ /** The outermost node_modules directory containing path, or undefined. */
3
+ function nodeModulesRoot(path) {
4
+ const segments = path.split("/");
5
+ const index = segments.indexOf(SEGMENT);
6
+ return index === -1 ? undefined : segments.slice(0, index + 1).join("/");
7
+ }
8
+ export function isInNodeModules(path) {
9
+ return nodeModulesRoot(path) !== undefined;
10
+ }
11
+ /**
12
+ * croak repo checks a repo before install, so installed dependencies in it are
13
+ * a signal on their own: a committed node_modules is a classic hiding place.
14
+ * One finding per directory instead of one per file keeps installed projects
15
+ * readable.
16
+ */
17
+ export const nodeModulesPresent = (repo) => {
18
+ const counts = new Map();
19
+ for (const { path } of repo.entries) {
20
+ const root = nodeModulesRoot(path);
21
+ if (root !== undefined)
22
+ counts.set(root, (counts.get(root) ?? 0) + 1);
23
+ }
24
+ return [...counts]
25
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
26
+ .map(([root, count]) => ({
27
+ ruleId: "repo/node-modules-present",
28
+ severity: "warn",
29
+ file: root,
30
+ message: `node_modules present (${count} ${count === 1 ? "file" : "files"}): croak repo checks repos before install, so a fresh clone should not contain installed dependencies`,
31
+ }));
32
+ };
@@ -0,0 +1,124 @@
1
+ import { CODE_PATTERNS } from "../signals/code.js";
2
+ import { OBFUSCATION_PATTERNS, obfuscationSignals, } from "../signals/obfuscation.js";
3
+ import { isInNodeModules } from "./node-modules-present.js";
4
+ const RULE = "repo/obfuscation";
5
+ const LEAD = {
6
+ info: "unusual encoding, common in generated or vendored code",
7
+ warn: "looks obfuscated",
8
+ block: "obfuscated code",
9
+ };
10
+ const SCANNED = /\.[cm]?[jt]sx?$/;
11
+ /** Type declarations are never executed (eslint and playwright types name `globalThis[key](…)`). */
12
+ const DECLARATIONS = /\.d\.[cm]?ts$/;
13
+ /** Legitimate code has these too: icon-font escape maps, embedded fonts or wasm. */
14
+ const WEAK = new Set(["escapes", "encoded-blob"]);
15
+ /** Whole-file obfuscator output: there, eval or decoding anywhere counts. */
16
+ const FILE_WIDE = new Set(["obfuscator-names"]);
17
+ /** Lines within which a signal and a call count as one construct… */
18
+ const NEARBY_LINES = 3;
19
+ /** …on ordinary lines. A minified file is one giant line, so there "nearby" is measured in characters. */
20
+ const LONG_LINE = 1000;
21
+ const NEARBY_CHARS = 200;
22
+ const patternsOf = (...signals) => CODE_PATTERNS.filter((p) => signals.includes(p.signal)).map((p) => p.pattern);
23
+ /** What runs code. Decoding alone only makes data (a parser's Unicode tables, wasm). */
24
+ const EXECUTES = patternsOf("eval");
25
+ const EXECUTES_OR_DECODES = patternsOf("eval", "decode");
26
+ /** Every match of `patterns` as an absolute character span, with its line. */
27
+ function spans(lines, patterns, onLines) {
28
+ const found = [];
29
+ let offset = 0;
30
+ lines.forEach((text, index) => {
31
+ const line = index + 1;
32
+ if (onLines === undefined || onLines.has(line)) {
33
+ for (const pattern of patterns) {
34
+ for (const m of text.matchAll(new RegExp(pattern.source, "g"))) {
35
+ const start = offset + (m.index ?? 0);
36
+ found.push({ line, start, end: start + m[0].length });
37
+ }
38
+ }
39
+ }
40
+ offset += text.length + 1;
41
+ });
42
+ return found;
43
+ }
44
+ const gap = (a, b) => Math.max(0, Math.max(a.start, b.start) - Math.min(a.end, b.end));
45
+ /**
46
+ * A signal is next to a call when enough of its matches (its per-line
47
+ * threshold, e.g. 8 escapes) sit within NEARBY_CHARS of the call, or, on
48
+ * ordinary short lines, within NEARBY_LINES of it.
49
+ */
50
+ function nearCall(lines, hit, calls) {
51
+ const patterns = OBFUSCATION_PATTERNS.filter((p) => p.kind === hit.kind);
52
+ const threshold = Math.min(...patterns.map((p) => p.threshold));
53
+ const short = (line) => (lines[line - 1]?.length ?? 0) <= LONG_LINE;
54
+ const marks = spans(lines, patterns.map((p) => p.pattern), new Set(hit.lines));
55
+ return calls.some((call) => {
56
+ if (short(call.line) &&
57
+ hit.lines.some((at) => short(at) && Math.abs(at - call.line) <= NEARBY_LINES))
58
+ return true;
59
+ return (marks.filter((m) => gap(m, call) <= NEARBY_CHARS).length >= threshold);
60
+ });
61
+ }
62
+ /**
63
+ * Weak signals alone are context; one strong signal warns. It blocks when
64
+ * two kinds meet in one construct, when obfuscator output has eval or decoding anywhere, when
65
+ * a local strong signal (a computed global call, a rotated string array) sits
66
+ * next to eval or decoding, or when a weak signal sits next to eval — a
67
+ * Unicode regex beside String.fromCharCode in a parser is not one construct.
68
+ */
69
+ function assess(lines, hits) {
70
+ const kinds = new Set(hits.map((hit) => hit.kind));
71
+ const strong = [...kinds].filter((kind) => !WEAK.has(kind)).length;
72
+ // Two kinds are one construct when obfuscator output covers the file, or
73
+ // when a strong signal sits next to the other kind (not 10,000 lines away
74
+ // in a browser bundle, as in mocha.js).
75
+ const markedBy = (hit) => spans(lines, OBFUSCATION_PATTERNS.filter((p) => p.kind === hit.kind).map((p) => p.pattern), new Set(hit.lines));
76
+ const together = hits.some((hit) => FILE_WIDE.has(hit.kind)) ||
77
+ hits.some((hit) => !WEAK.has(hit.kind) &&
78
+ hits.some((other) => other.kind !== hit.kind && nearCall(lines, hit, markedBy(other))));
79
+ if (strong >= 1 && kinds.size >= 2 && together)
80
+ return { raw: "block", why: "" };
81
+ const executes = spans(lines, EXECUTES);
82
+ const decodes = spans(lines, EXECUTES_OR_DECODES);
83
+ const near = hits.some((hit) => FILE_WIDE.has(hit.kind)
84
+ ? decodes.length > 0
85
+ : nearCall(lines, hit, WEAK.has(hit.kind) ? executes : decodes));
86
+ if (near)
87
+ return { raw: "block", why: ", next to eval or decoding" };
88
+ return { raw: strong >= 1 ? "warn" : "info", why: "" };
89
+ }
90
+ function fileFinding(path, text, downgradeInstalled) {
91
+ const hits = obfuscationSignals(text);
92
+ const first = hits[0];
93
+ if (first === undefined)
94
+ return undefined;
95
+ const { raw, why } = assess(text.split("\n"), hits);
96
+ // As with other content detectors: installed and committed dependencies look
97
+ // the same, and repo/node-modules-present already flags the directory.
98
+ // (In a package, node_modules is bundled code npm runs: no downgrade.)
99
+ const severity = downgradeInstalled && isInNodeModules(path) && raw === "block"
100
+ ? "warn"
101
+ : raw;
102
+ const seen = hits.map((hit) => `${hit.kind} (line ${hit.line})`).join(", ");
103
+ return {
104
+ ruleId: RULE,
105
+ severity,
106
+ file: path,
107
+ line: first.line,
108
+ message: `${LEAD[severity]}: ${seen}${raw === "block" ? why : ""}`,
109
+ };
110
+ }
111
+ /** Code made deliberately unreadable — not merely minified. */
112
+ export function obfuscationWith({ downgradeInstalled, }) {
113
+ return (repo) => repo.entries
114
+ .filter((e) => e.kind === "file" &&
115
+ SCANNED.test(e.path) &&
116
+ !DECLARATIONS.test(e.path))
117
+ .flatMap((e) => {
118
+ const finding = fileFinding(e.path, repo.read(e.path) ?? "", downgradeInstalled);
119
+ return finding === undefined ? [] : [finding];
120
+ });
121
+ }
122
+ export const obfuscation = obfuscationWith({
123
+ downgradeInstalled: true,
124
+ });