@vola-trebla/croak 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +283 -2
- package/dist/cli.js +347 -0
- package/dist/crypto/der.js +123 -0
- package/dist/crypto/fields.js +49 -0
- package/dist/crypto/registry-signature.js +76 -0
- package/dist/crypto/rekor.js +57 -0
- package/dist/crypto/sigstore.js +112 -0
- package/dist/detectors/git-config.js +133 -0
- package/dist/detectors/hidden-code.js +51 -0
- package/dist/detectors/ide-autorun.js +127 -0
- package/dist/detectors/index.js +19 -0
- package/dist/detectors/lifecycle-script.js +298 -0
- package/dist/detectors/node-modules-present.js +25 -0
- package/dist/detectors/obfuscation.js +98 -0
- package/dist/detectors/unscanned.js +33 -0
- package/dist/hook/hook.js +348 -0
- package/dist/hook/install-commands.js +892 -0
- package/dist/install/content-cache.js +86 -0
- package/dist/install/content.js +32 -0
- package/dist/install/detectors.js +73 -0
- package/dist/install/installed.js +83 -0
- package/dist/install/lockfile.js +65 -0
- package/dist/install/metadata.js +129 -0
- package/dist/install/plan.js +21 -0
- package/dist/install/pnpm-builds.js +98 -0
- package/dist/install/pnpm-lockfile.js +88 -0
- package/dist/install/popular.js +172 -0
- package/dist/install/project.js +203 -0
- package/dist/install/provenance.js +56 -0
- package/dist/install/registry.js +241 -0
- package/dist/install/report.js +26 -0
- package/dist/install/sanitize.js +74 -0
- package/dist/install/scan.js +271 -0
- package/dist/install/signature.js +55 -0
- package/dist/install/tar.js +171 -0
- package/dist/install/workspace.js +30 -0
- package/dist/install/yaml.js +256 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/cost.js +24 -0
- package/dist/llm/diff.js +100 -0
- package/dist/llm/escalate.js +14 -0
- package/dist/llm/plan-step.js +86 -0
- package/dist/llm/select.js +15 -0
- package/dist/llm/triage.js +101 -0
- package/dist/repo-report.js +84 -0
- package/dist/repo-snapshot.js +140 -0
- package/dist/scan.js +43 -0
- package/dist/signals/code.js +107 -0
- package/dist/signals/git-config.js +51 -0
- package/dist/signals/hidden.js +60 -0
- package/dist/signals/jsonc.js +90 -0
- package/dist/signals/obfuscation.js +87 -0
- package/dist/signals/shell.js +677 -0
- package/dist/signals/text.js +1 -0
- package/dist/ui.js +11 -0
- package/dist/verdict.js +19 -0
- package/package.json +71 -4
- package/scripts/croak-hook.sh +59 -0
- package/scripts/croak-install.sh +262 -0
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
function folderOpenLines(text) {
|
|
15
|
+
return text
|
|
16
|
+
.split("\n")
|
|
17
|
+
.flatMap((line, index) => (line.includes("folderOpen") ? [index + 1] : []));
|
|
18
|
+
}
|
|
19
|
+
function manifestFor(repo, tasksPath) {
|
|
20
|
+
const folder = posix.dirname(posix.dirname(tasksPath));
|
|
21
|
+
const dir = folder === "." ? "" : folder;
|
|
22
|
+
const text = repo.read(dir === "" ? "package.json" : `${dir}/package.json`);
|
|
23
|
+
if (text === undefined)
|
|
24
|
+
return { dir, scripts: {} };
|
|
25
|
+
try {
|
|
26
|
+
const manifest = JSON.parse(stripBom(text));
|
|
27
|
+
return {
|
|
28
|
+
dir,
|
|
29
|
+
scripts: isRecord(manifest) && isRecord(manifest.scripts)
|
|
30
|
+
? manifest.scripts
|
|
31
|
+
: {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { dir, scripts: {} };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function taskFinding(repo, path, task, line, trigger) {
|
|
39
|
+
const label = asString(task.label) ?? "(unnamed task)";
|
|
40
|
+
const presentation = isRecord(task.presentation) ? task.presentation : {};
|
|
41
|
+
const hidden = presentation.reveal === "never" ? ", with its terminal hidden" : "";
|
|
42
|
+
const at = line === undefined ? { file: path } : { file: path, line };
|
|
43
|
+
if (task.type === "npm") {
|
|
44
|
+
const script = asString(task.script) ?? "";
|
|
45
|
+
const { dir, scripts } = manifestFor(repo, path);
|
|
46
|
+
const command = asString(scripts[script]);
|
|
47
|
+
const assessment = command === undefined ? undefined : assessor(repo, dir, scripts)(command);
|
|
48
|
+
const shown = command === undefined
|
|
49
|
+
? "not found in package.json"
|
|
50
|
+
: `\`${truncate(command)}\``;
|
|
51
|
+
const reasons = assessment !== undefined && assessment.reasons.length > 0
|
|
52
|
+
? `: ${assessment.reasons.join("; ")}`
|
|
53
|
+
: "";
|
|
54
|
+
return {
|
|
55
|
+
ruleId: RULE,
|
|
56
|
+
severity: assessment?.block === true ? "block" : "warn",
|
|
57
|
+
...at,
|
|
58
|
+
message: `task "${label}" runs npm script "${script}" (${shown}) ${trigger}${hidden}${reasons}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const args = Array.isArray(task.args)
|
|
62
|
+
? task.args.filter((a) => typeof a === "string")
|
|
63
|
+
: [];
|
|
64
|
+
const command = [asString(task.command) ?? "(command)", ...args].join(" ");
|
|
65
|
+
return {
|
|
66
|
+
ruleId: RULE,
|
|
67
|
+
severity: "block",
|
|
68
|
+
...at,
|
|
69
|
+
message: `task "${label}" runs \`${truncate(command)}\` automatically ${trigger}${hidden}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const dependsOnLabels = (task) => {
|
|
73
|
+
const deps = task.dependsOn;
|
|
74
|
+
if (typeof deps === "string")
|
|
75
|
+
return [deps];
|
|
76
|
+
return Array.isArray(deps)
|
|
77
|
+
? deps.filter((d) => typeof d === "string")
|
|
78
|
+
: [];
|
|
79
|
+
};
|
|
80
|
+
function tasksFindings(repo, path) {
|
|
81
|
+
const text = repo.read(path);
|
|
82
|
+
if (text === undefined)
|
|
83
|
+
return [];
|
|
84
|
+
let config;
|
|
85
|
+
try {
|
|
86
|
+
config = parseJsonc(stripBom(text));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!(error instanceof JsoncError))
|
|
90
|
+
throw error;
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
ruleId: RULE,
|
|
94
|
+
severity: "warn",
|
|
95
|
+
file: path,
|
|
96
|
+
message: `tasks.json could not be parsed (${error.message}); VS Code may still run its tasks`,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
const tasks = isRecord(config) && Array.isArray(config.tasks)
|
|
101
|
+
? config.tasks.filter(isRecord)
|
|
102
|
+
: [];
|
|
103
|
+
const byLabel = new Map(tasks.flatMap((task) => typeof task.label === "string" ? [[task.label, task]] : []));
|
|
104
|
+
const autorun = tasks.filter((task) => isRecord(task.runOptions) && task.runOptions.runOn === "folderOpen");
|
|
105
|
+
const lines = folderOpenLines(text);
|
|
106
|
+
const findings = autorun.map((task, index) => taskFinding(repo, path, task, lines[index], "when the folder is opened"));
|
|
107
|
+
const seen = new Set(autorun);
|
|
108
|
+
const queue = autorun.flatMap((task) => dependsOnLabels(task).map((dep) => ({
|
|
109
|
+
dep,
|
|
110
|
+
parent: asString(task.label) ?? "",
|
|
111
|
+
})));
|
|
112
|
+
while (queue.length > 0) {
|
|
113
|
+
const next = queue.shift();
|
|
114
|
+
const task = next === undefined ? undefined : byLabel.get(next.dep);
|
|
115
|
+
if (next === undefined || task === undefined || seen.has(task))
|
|
116
|
+
continue;
|
|
117
|
+
seen.add(task);
|
|
118
|
+
findings.push(taskFinding(repo, path, task, undefined, `when the folder is opened (folder-open task "${next.parent}" depends on it)`));
|
|
119
|
+
queue.push(...dependsOnLabels(task).map((dep) => ({ dep, parent: next.dep })));
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
export const ideAutorun = (repo) => repo.entries
|
|
124
|
+
.filter((e) => e.kind === "file" &&
|
|
125
|
+
(e.path === TASKS_FILE || e.path.endsWith(`/${TASKS_FILE}`)) &&
|
|
126
|
+
!isInNodeModules(e.path))
|
|
127
|
+
.flatMap((e) => tasksFindings(repo, e.path));
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
export const repoDetectors = [
|
|
9
|
+
unscanned,
|
|
10
|
+
nodeModulesPresent,
|
|
11
|
+
lifecycleScript,
|
|
12
|
+
ideAutorun,
|
|
13
|
+
hiddenCodeDetector,
|
|
14
|
+
obfuscation,
|
|
15
|
+
gitConfig,
|
|
16
|
+
];
|
|
17
|
+
export function scanRepo(repo, detectors = repoDetectors) {
|
|
18
|
+
return detectors.flatMap((detect) => detect(repo));
|
|
19
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
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
|
+
export const INSTALL_SCRIPTS = [
|
|
8
|
+
"preinstall",
|
|
9
|
+
"install",
|
|
10
|
+
"postinstall",
|
|
11
|
+
"prepublish",
|
|
12
|
+
"preprepare",
|
|
13
|
+
"prepare",
|
|
14
|
+
"postprepare",
|
|
15
|
+
"dependencies",
|
|
16
|
+
];
|
|
17
|
+
export const DEPENDENCY_INSTALL_SCRIPTS = [
|
|
18
|
+
"preinstall",
|
|
19
|
+
"install",
|
|
20
|
+
"postinstall",
|
|
21
|
+
];
|
|
22
|
+
const BUNDLED_MANIFEST = /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/@][^/]*\/package\.json$/;
|
|
23
|
+
const INERT_TOOLS = new Set([
|
|
24
|
+
"husky",
|
|
25
|
+
"simple-git-hooks",
|
|
26
|
+
"lefthook",
|
|
27
|
+
"only-allow",
|
|
28
|
+
"opencollective-postinstall",
|
|
29
|
+
"playwright",
|
|
30
|
+
"tsc",
|
|
31
|
+
"esbuild",
|
|
32
|
+
"swc",
|
|
33
|
+
"echo",
|
|
34
|
+
"true",
|
|
35
|
+
"exit",
|
|
36
|
+
"printf",
|
|
37
|
+
]);
|
|
38
|
+
const EXECUTING_TOOLS = new Set([
|
|
39
|
+
"prisma",
|
|
40
|
+
"vite",
|
|
41
|
+
"webpack",
|
|
42
|
+
"rollup",
|
|
43
|
+
"tsup",
|
|
44
|
+
"unbuild",
|
|
45
|
+
"babel",
|
|
46
|
+
"nuxt",
|
|
47
|
+
"nuxi",
|
|
48
|
+
"svelte-kit",
|
|
49
|
+
"astro",
|
|
50
|
+
"ngcc",
|
|
51
|
+
"turbo",
|
|
52
|
+
"nx",
|
|
53
|
+
"lerna",
|
|
54
|
+
"patch-package",
|
|
55
|
+
"rimraf",
|
|
56
|
+
]);
|
|
57
|
+
const KNOWN_TOOLS = new Set([...INERT_TOOLS, ...EXECUTING_TOOLS]);
|
|
58
|
+
const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
59
|
+
const MAX_SCRIPT_DEPTH = 5;
|
|
60
|
+
const SHELL_DECODE = /\bbase64\s+(?:-d|-D|--decode)\b|\bopenssl\s+(?:enc|base64)\b[^|;&]*\s-d\b/;
|
|
61
|
+
const MAX_SHOWN = 80;
|
|
62
|
+
function isRecord(value) {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
|
|
66
|
+
function scriptLine(manifest, name) {
|
|
67
|
+
const lines = manifest.split("\n");
|
|
68
|
+
const scripts = lines.findIndex((line) => /"scripts"\s*:/.test(line));
|
|
69
|
+
const key = `"${name}"`;
|
|
70
|
+
const index = lines.findIndex((line, i) => i >= Math.max(scripts, 0) &&
|
|
71
|
+
line.includes(key) &&
|
|
72
|
+
/"\s*:/.test(line.slice(line.indexOf(key))));
|
|
73
|
+
return index === -1 ? undefined : index + 1;
|
|
74
|
+
}
|
|
75
|
+
function routineChecker(scripts) {
|
|
76
|
+
const memo = new Map();
|
|
77
|
+
const scriptIsRoutine = (name, depth) => {
|
|
78
|
+
const known = memo.get(name);
|
|
79
|
+
if (known !== undefined)
|
|
80
|
+
return known;
|
|
81
|
+
memo.set(name, false);
|
|
82
|
+
const body = scripts[name];
|
|
83
|
+
const result = typeof body === "string" &&
|
|
84
|
+
commandIsRoutine(body, depth + 1) &&
|
|
85
|
+
[`pre${name}`, `post${name}`].every((hook) => !(hook in scripts) || scriptIsRoutine(hook, depth + 1));
|
|
86
|
+
memo.set(name, result);
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
const commandIsRoutine = (command, depth) => {
|
|
90
|
+
if (depth > MAX_SCRIPT_DEPTH)
|
|
91
|
+
return false;
|
|
92
|
+
const facts = analyzeShell(command);
|
|
93
|
+
if (facts.unparsable ||
|
|
94
|
+
facts.downloadExecute ||
|
|
95
|
+
facts.network ||
|
|
96
|
+
facts.localFiles.length > 0 ||
|
|
97
|
+
facts.inlineCode.length > 0 ||
|
|
98
|
+
facts.modifiers.length > 0 ||
|
|
99
|
+
codeSignals(command).length > 0 ||
|
|
100
|
+
facts.commands.length === 0) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const pmCalls = facts.commands.filter((name) => PACKAGE_MANAGERS.has(name)).length;
|
|
104
|
+
if (pmCalls !== facts.runScripts.length)
|
|
105
|
+
return false;
|
|
106
|
+
if (!facts.commands.every((name) => INERT_TOOLS.has(name) || PACKAGE_MANAGERS.has(name)))
|
|
107
|
+
return false;
|
|
108
|
+
return facts.runScripts.every((name) => scriptIsRoutine(name, depth));
|
|
109
|
+
};
|
|
110
|
+
return (command) => commandIsRoutine(command, 0);
|
|
111
|
+
}
|
|
112
|
+
export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
|
|
113
|
+
const memo = new Map();
|
|
114
|
+
const empty = {
|
|
115
|
+
block: false,
|
|
116
|
+
reasons: [],
|
|
117
|
+
knownTools: [],
|
|
118
|
+
signals: new Set(),
|
|
119
|
+
};
|
|
120
|
+
const assessScript = (name, depth) => {
|
|
121
|
+
const known = memo.get(name);
|
|
122
|
+
if (known !== undefined)
|
|
123
|
+
return known;
|
|
124
|
+
memo.set(name, empty);
|
|
125
|
+
const body = scripts[name];
|
|
126
|
+
const result = typeof body !== "string"
|
|
127
|
+
? {
|
|
128
|
+
...empty,
|
|
129
|
+
reasons: [`calls script "${name}", which is not in package.json`],
|
|
130
|
+
}
|
|
131
|
+
: assessCommand(body, depth + 1);
|
|
132
|
+
memo.set(name, result);
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
const assessCommand = (command, depth) => {
|
|
136
|
+
if (depth > MAX_SCRIPT_DEPTH)
|
|
137
|
+
return { ...empty, reasons: ["scripts are nested too deeply to follow"] };
|
|
138
|
+
const facts = analyzeShell(command);
|
|
139
|
+
if (facts.unparsable) {
|
|
140
|
+
return facts.mentionsDownloader
|
|
141
|
+
? {
|
|
142
|
+
...empty,
|
|
143
|
+
block: true,
|
|
144
|
+
reasons: [
|
|
145
|
+
"could not be parsed and mentions a download tool (failing closed)",
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
: {
|
|
149
|
+
...empty,
|
|
150
|
+
reasons: [
|
|
151
|
+
"the command could not be parsed, so croak cannot tell what it runs",
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const reasons = [];
|
|
156
|
+
const signals = new Set();
|
|
157
|
+
const knownTools = new Set(facts.commands.filter((name) => KNOWN_TOOLS.has(name)));
|
|
158
|
+
let nestedBlock = false;
|
|
159
|
+
const absorb = (hits, where) => {
|
|
160
|
+
if (hits.length === 0)
|
|
161
|
+
return;
|
|
162
|
+
for (const hit of hits)
|
|
163
|
+
signals.add(hit.signal);
|
|
164
|
+
reasons.push(describeSignals(hits, where));
|
|
165
|
+
};
|
|
166
|
+
if (facts.downloadExecute)
|
|
167
|
+
reasons.push("downloads and executes code");
|
|
168
|
+
if (facts.network) {
|
|
169
|
+
signals.add("network");
|
|
170
|
+
reasons.push("uses the network");
|
|
171
|
+
}
|
|
172
|
+
if (SHELL_DECODE.test(command))
|
|
173
|
+
signals.add("decode");
|
|
174
|
+
absorb(codeSignals(command), "the command");
|
|
175
|
+
for (const code of facts.inlineCode)
|
|
176
|
+
absorb(codeSignals(code), "inline code");
|
|
177
|
+
for (const file of facts.localFiles) {
|
|
178
|
+
const path = posix.normalize(posix.join(dir, file));
|
|
179
|
+
if (path === ".." || path.startsWith("../")) {
|
|
180
|
+
reasons.push(`runs ${file}, a path outside the repo`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const entry = repo.entries.find((e) => e.path === path);
|
|
184
|
+
if (entry === undefined)
|
|
185
|
+
reasons.push(`runs ${path}, which is not in the repo`);
|
|
186
|
+
else if (entry.kind === "oversized")
|
|
187
|
+
reasons.push(`runs ${path}, too large to scan`);
|
|
188
|
+
else if (entry.kind === "symlink")
|
|
189
|
+
reasons.push(`runs ${path}, a symlink croak does not follow`);
|
|
190
|
+
else
|
|
191
|
+
absorb(codeSignals(repo.read(path) ?? ""), path);
|
|
192
|
+
}
|
|
193
|
+
for (const name of facts.runScripts) {
|
|
194
|
+
for (const script of [`pre${name}`, name, `post${name}`]) {
|
|
195
|
+
if (script !== name && !(script in scripts))
|
|
196
|
+
continue;
|
|
197
|
+
const sub = assessScript(script, depth);
|
|
198
|
+
nestedBlock ||= sub.block;
|
|
199
|
+
for (const signal of sub.signals)
|
|
200
|
+
signals.add(signal);
|
|
201
|
+
for (const tool of sub.knownTools)
|
|
202
|
+
knownTools.add(tool);
|
|
203
|
+
if (sub.reasons.length > 0)
|
|
204
|
+
reasons.push(`via script "${script}": ${sub.reasons.join("; ")}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
block: facts.downloadExecute || nestedBlock || suspicious(signals),
|
|
209
|
+
reasons,
|
|
210
|
+
knownTools: [...knownTools],
|
|
211
|
+
signals,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
return (command) => assessCommand(command, 0);
|
|
215
|
+
}
|
|
216
|
+
function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
|
|
217
|
+
const dir = posix.dirname(path) === "." ? "" : posix.dirname(path);
|
|
218
|
+
const text = repo.read(path);
|
|
219
|
+
if (text === undefined)
|
|
220
|
+
return [];
|
|
221
|
+
let manifest;
|
|
222
|
+
try {
|
|
223
|
+
manifest = JSON.parse(stripBom(text));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
ruleId: RULE,
|
|
229
|
+
severity: "warn",
|
|
230
|
+
file: path,
|
|
231
|
+
message: "package.json is not valid JSON; croak cannot see its install scripts",
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
const scripts = isRecord(manifest) && isRecord(manifest.scripts) ? manifest.scripts : {};
|
|
236
|
+
const findings = [];
|
|
237
|
+
const isRoutine = routineChecker(scripts);
|
|
238
|
+
const assess = assessor(repo, dir, scripts, suspicious);
|
|
239
|
+
for (const name of installScripts) {
|
|
240
|
+
const command = scripts[name];
|
|
241
|
+
if (typeof command !== "string")
|
|
242
|
+
continue;
|
|
243
|
+
const { block, reasons, knownTools } = assess(command);
|
|
244
|
+
const line = scriptLine(text, name);
|
|
245
|
+
const at = line === undefined ? { file: path } : { file: path, line };
|
|
246
|
+
if (!block && isRoutine(command)) {
|
|
247
|
+
findings.push({
|
|
248
|
+
ruleId: RULE,
|
|
249
|
+
severity: "info",
|
|
250
|
+
...at,
|
|
251
|
+
message: `${name} runs \`${truncate(command)}\` on install: known tools only, nothing suspicious`,
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
findings.push({
|
|
256
|
+
ruleId: RULE,
|
|
257
|
+
severity: block ? "block" : "warn",
|
|
258
|
+
...at,
|
|
259
|
+
message: `${name} runs \`${truncate(command)}\` on install${reasons.length > 0 ? `: ${reasons.join("; ")}` : ""}`,
|
|
260
|
+
});
|
|
261
|
+
for (const tool of knownTools) {
|
|
262
|
+
findings.push({
|
|
263
|
+
ruleId: RULE,
|
|
264
|
+
severity: "info",
|
|
265
|
+
...at,
|
|
266
|
+
message: `${name} uses ${tool}, a known install-time tool`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const gyp = posix.join(dir, "binding.gyp");
|
|
271
|
+
if (!("install" in scripts) &&
|
|
272
|
+
!("preinstall" in scripts) &&
|
|
273
|
+
repo.entries.some((e) => e.path === gyp)) {
|
|
274
|
+
findings.push({
|
|
275
|
+
ruleId: RULE,
|
|
276
|
+
severity: "warn",
|
|
277
|
+
file: gyp,
|
|
278
|
+
message: "binding.gyp makes npm run `node-gyp rebuild` on install; gyp files can run arbitrary build actions",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return findings;
|
|
282
|
+
}
|
|
283
|
+
export function lifecycleScriptWith(policy) {
|
|
284
|
+
return (repo) => repo.entries
|
|
285
|
+
.filter((e) => e.kind === "file" &&
|
|
286
|
+
(policy.manifests === "root"
|
|
287
|
+
? e.path === "package.json"
|
|
288
|
+
: policy.manifests === "package"
|
|
289
|
+
? e.path === "package.json" || BUNDLED_MANIFEST.test(e.path)
|
|
290
|
+
: posix.basename(e.path) === "package.json" &&
|
|
291
|
+
!isInNodeModules(e.path)))
|
|
292
|
+
.flatMap((e) => manifestFindings(repo, e.path, policy));
|
|
293
|
+
}
|
|
294
|
+
export const lifecycleScript = lifecycleScriptWith({
|
|
295
|
+
suspicious: isSuspicious,
|
|
296
|
+
scripts: INSTALL_SCRIPTS,
|
|
297
|
+
manifests: "repo",
|
|
298
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const SEGMENT = "node_modules";
|
|
2
|
+
function nodeModulesRoot(path) {
|
|
3
|
+
const segments = path.split("/");
|
|
4
|
+
const index = segments.indexOf(SEGMENT);
|
|
5
|
+
return index === -1 ? undefined : segments.slice(0, index + 1).join("/");
|
|
6
|
+
}
|
|
7
|
+
export function isInNodeModules(path) {
|
|
8
|
+
return nodeModulesRoot(path) !== undefined;
|
|
9
|
+
}
|
|
10
|
+
export const nodeModulesPresent = (repo) => {
|
|
11
|
+
const counts = new Map();
|
|
12
|
+
for (const { path } of repo.entries) {
|
|
13
|
+
const root = nodeModulesRoot(path);
|
|
14
|
+
if (root !== undefined)
|
|
15
|
+
counts.set(root, (counts.get(root) ?? 0) + 1);
|
|
16
|
+
}
|
|
17
|
+
return [...counts]
|
|
18
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
19
|
+
.map(([root, count]) => ({
|
|
20
|
+
ruleId: "repo/node-modules-present",
|
|
21
|
+
severity: "warn",
|
|
22
|
+
file: root,
|
|
23
|
+
message: `node_modules present (${count} ${count === 1 ? "file" : "files"}): croak repo checks repos before install, so a fresh clone should not contain installed dependencies`,
|
|
24
|
+
}));
|
|
25
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
const DECLARATIONS = /\.d\.[cm]?ts$/;
|
|
12
|
+
const WEAK = new Set(["escapes", "encoded-blob"]);
|
|
13
|
+
const FILE_WIDE = new Set(["obfuscator-names"]);
|
|
14
|
+
const NEARBY_LINES = 3;
|
|
15
|
+
const LONG_LINE = 1000;
|
|
16
|
+
const NEARBY_CHARS = 200;
|
|
17
|
+
const patternsOf = (...signals) => CODE_PATTERNS.filter((p) => signals.includes(p.signal)).map((p) => p.pattern);
|
|
18
|
+
const EXECUTES = patternsOf("eval");
|
|
19
|
+
const EXECUTES_OR_DECODES = patternsOf("eval", "decode");
|
|
20
|
+
function spans(lines, patterns, onLines) {
|
|
21
|
+
const found = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
lines.forEach((text, index) => {
|
|
24
|
+
const line = index + 1;
|
|
25
|
+
if (onLines === undefined || onLines.has(line)) {
|
|
26
|
+
for (const pattern of patterns) {
|
|
27
|
+
for (const m of text.matchAll(new RegExp(pattern.source, "g"))) {
|
|
28
|
+
const start = offset + (m.index ?? 0);
|
|
29
|
+
found.push({ line, start, end: start + m[0].length });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
offset += text.length + 1;
|
|
34
|
+
});
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
const gap = (a, b) => Math.max(0, Math.max(a.start, b.start) - Math.min(a.end, b.end));
|
|
38
|
+
function nearCall(lines, hit, calls) {
|
|
39
|
+
const patterns = OBFUSCATION_PATTERNS.filter((p) => p.kind === hit.kind);
|
|
40
|
+
const threshold = Math.min(...patterns.map((p) => p.threshold));
|
|
41
|
+
const short = (line) => (lines[line - 1]?.length ?? 0) <= LONG_LINE;
|
|
42
|
+
const marks = spans(lines, patterns.map((p) => p.pattern), new Set(hit.lines));
|
|
43
|
+
return calls.some((call) => {
|
|
44
|
+
if (short(call.line) &&
|
|
45
|
+
hit.lines.some((at) => short(at) && Math.abs(at - call.line) <= NEARBY_LINES))
|
|
46
|
+
return true;
|
|
47
|
+
return (marks.filter((m) => gap(m, call) <= NEARBY_CHARS).length >= threshold);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function assess(lines, hits) {
|
|
51
|
+
const kinds = new Set(hits.map((hit) => hit.kind));
|
|
52
|
+
const strong = [...kinds].filter((kind) => !WEAK.has(kind)).length;
|
|
53
|
+
const markedBy = (hit) => spans(lines, OBFUSCATION_PATTERNS.filter((p) => p.kind === hit.kind).map((p) => p.pattern), new Set(hit.lines));
|
|
54
|
+
const together = hits.some((hit) => FILE_WIDE.has(hit.kind)) ||
|
|
55
|
+
hits.some((hit) => !WEAK.has(hit.kind) &&
|
|
56
|
+
hits.some((other) => other.kind !== hit.kind && nearCall(lines, hit, markedBy(other))));
|
|
57
|
+
if (strong >= 1 && kinds.size >= 2 && together)
|
|
58
|
+
return { raw: "block", why: "" };
|
|
59
|
+
const executes = spans(lines, EXECUTES);
|
|
60
|
+
const decodes = spans(lines, EXECUTES_OR_DECODES);
|
|
61
|
+
const near = hits.some((hit) => FILE_WIDE.has(hit.kind)
|
|
62
|
+
? decodes.length > 0
|
|
63
|
+
: nearCall(lines, hit, WEAK.has(hit.kind) ? executes : decodes));
|
|
64
|
+
if (near)
|
|
65
|
+
return { raw: "block", why: ", next to eval or decoding" };
|
|
66
|
+
return { raw: strong >= 1 ? "warn" : "info", why: "" };
|
|
67
|
+
}
|
|
68
|
+
function fileFinding(path, text, downgradeInstalled) {
|
|
69
|
+
const hits = obfuscationSignals(text);
|
|
70
|
+
const first = hits[0];
|
|
71
|
+
if (first === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
const { raw, why } = assess(text.split("\n"), hits);
|
|
74
|
+
const severity = downgradeInstalled && isInNodeModules(path) && raw === "block"
|
|
75
|
+
? "warn"
|
|
76
|
+
: raw;
|
|
77
|
+
const seen = hits.map((hit) => `${hit.kind} (line ${hit.line})`).join(", ");
|
|
78
|
+
return {
|
|
79
|
+
ruleId: RULE,
|
|
80
|
+
severity,
|
|
81
|
+
file: path,
|
|
82
|
+
line: first.line,
|
|
83
|
+
message: `${LEAD[severity]}: ${seen}${raw === "block" ? why : ""}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function obfuscationWith({ downgradeInstalled, }) {
|
|
87
|
+
return (repo) => repo.entries
|
|
88
|
+
.filter((e) => e.kind === "file" &&
|
|
89
|
+
SCANNED.test(e.path) &&
|
|
90
|
+
!DECLARATIONS.test(e.path))
|
|
91
|
+
.flatMap((e) => {
|
|
92
|
+
const finding = fileFinding(e.path, repo.read(e.path) ?? "", downgradeInstalled);
|
|
93
|
+
return finding === undefined ? [] : [finding];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export const obfuscation = obfuscationWith({
|
|
97
|
+
downgradeInstalled: true,
|
|
98
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isInNodeModules } from "./node-modules-present.js";
|
|
2
|
+
const CODE = /\.(?:[cm]?[jt]sx?|json|sh|bash|zsh|py|rb|pl|ps1|gyp)$/;
|
|
3
|
+
const LOCKFILES = new Set(["package-lock.json", "npm-shrinkwrap.json"]);
|
|
4
|
+
const DECLARATIONS = /\.d\.[cm]?ts$/;
|
|
5
|
+
export function unscannedWith({ jsonIsData, downgradeInstalled, }) {
|
|
6
|
+
return (repo) => repo.entries
|
|
7
|
+
.filter((entry) => entry.kind === "oversized")
|
|
8
|
+
.filter((entry) => {
|
|
9
|
+
const name = entry.path.slice(entry.path.lastIndexOf("/") + 1);
|
|
10
|
+
return (entry.path.startsWith(".git/") ||
|
|
11
|
+
/(?:^|\/)\.husky\//.test(entry.path) ||
|
|
12
|
+
(CODE.test(name) && !LOCKFILES.has(name) && !DECLARATIONS.test(name)));
|
|
13
|
+
})
|
|
14
|
+
.map((entry) => {
|
|
15
|
+
const name = entry.path.slice(entry.path.lastIndexOf("/") + 1);
|
|
16
|
+
const data = jsonIsData && name.endsWith(".json") && name !== "package.json";
|
|
17
|
+
const installed = downgradeInstalled && isInNodeModules(entry.path);
|
|
18
|
+
return {
|
|
19
|
+
ruleId: "repo/unscanned",
|
|
20
|
+
severity: installed || data ? "info" : "warn",
|
|
21
|
+
file: entry.path,
|
|
22
|
+
message: data
|
|
23
|
+
? `${entry.size} bytes exceeds the read limit; not scanned (JSON data, not executed)`
|
|
24
|
+
: installed
|
|
25
|
+
? `${entry.size} bytes exceeds the read limit; not scanned (installed dependency)`
|
|
26
|
+
: `${entry.size} bytes exceeds the read limit; not scanned`,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export const unscanned = unscannedWith({
|
|
31
|
+
jsonIsData: false,
|
|
32
|
+
downgradeInstalled: true,
|
|
33
|
+
});
|