@vola-trebla/croak 0.1.0 → 0.1.3
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/dist/cli.js +1 -27
- package/dist/crypto/der.js +0 -13
- package/dist/crypto/fields.js +0 -11
- package/dist/crypto/registry-signature.js +0 -12
- package/dist/crypto/rekor.js +0 -13
- package/dist/crypto/sigstore.js +0 -9
- package/dist/detectors/git-config.js +0 -4
- package/dist/detectors/hidden-code.js +0 -7
- package/dist/detectors/ide-autorun.js +0 -9
- package/dist/detectors/index.js +0 -1
- package/dist/detectors/lifecycle-script.js +2 -41
- package/dist/detectors/node-modules-present.js +0 -7
- package/dist/detectors/obfuscation.js +0 -26
- package/dist/detectors/unscanned.js +0 -8
- package/dist/hook/hook.js +2 -45
- package/dist/hook/install-commands.js +1 -36
- package/dist/install/content-cache.js +0 -8
- package/dist/install/content.js +0 -10
- package/dist/install/detectors.js +30 -29
- package/dist/install/installed.js +3 -19
- package/dist/install/lockfile.js +0 -7
- package/dist/install/metadata.js +5 -32
- package/dist/install/plan.js +0 -10
- package/dist/install/pnpm-builds.js +0 -13
- package/dist/install/pnpm-lockfile.js +0 -14
- package/dist/install/popular.js +0 -5
- package/dist/install/project.js +0 -20
- package/dist/install/provenance.js +0 -21
- package/dist/install/registry.js +0 -33
- package/dist/install/sanitize.js +0 -14
- package/dist/install/scan.js +0 -31
- package/dist/install/signature.js +0 -7
- package/dist/install/tar.js +1 -8
- package/dist/install/workspace.js +0 -1
- package/dist/install/yaml.js +0 -26
- package/dist/llm/client.js +0 -8
- package/dist/llm/cost.js +0 -2
- package/dist/llm/diff.js +0 -14
- package/dist/llm/plan-step.js +0 -5
- package/dist/llm/select.js +0 -6
- package/dist/llm/triage.js +0 -10
- package/dist/repo-report.js +0 -11
- package/dist/repo-snapshot.js +0 -17
- package/dist/scan.js +0 -5
- package/dist/signals/code.js +0 -29
- package/dist/signals/git-config.js +0 -7
- package/dist/signals/hidden.js +0 -17
- package/dist/signals/jsonc.js +0 -10
- package/dist/signals/obfuscation.js +0 -11
- package/dist/signals/shell.js +0 -40
- package/dist/signals/text.js +0 -1
- package/dist/ui.js +0 -1
- package/dist/verdict.js +0 -1
- package/package.json +1 -1
- package/scripts/croak-hook.sh +4 -2
- package/scripts/croak-install.sh +14 -6
|
@@ -8,22 +8,15 @@ const LEAD = {
|
|
|
8
8
|
block: "obfuscated code",
|
|
9
9
|
};
|
|
10
10
|
const SCANNED = /\.[cm]?[jt]sx?$/;
|
|
11
|
-
/** Type declarations are never executed (eslint and playwright types name `globalThis[key](…)`). */
|
|
12
11
|
const DECLARATIONS = /\.d\.[cm]?ts$/;
|
|
13
|
-
/** Legitimate code has these too: icon-font escape maps, embedded fonts or wasm. */
|
|
14
12
|
const WEAK = new Set(["escapes", "encoded-blob"]);
|
|
15
|
-
/** Whole-file obfuscator output: there, eval or decoding anywhere counts. */
|
|
16
13
|
const FILE_WIDE = new Set(["obfuscator-names"]);
|
|
17
|
-
/** Lines within which a signal and a call count as one construct… */
|
|
18
14
|
const NEARBY_LINES = 3;
|
|
19
|
-
/** …on ordinary lines. A minified file is one giant line, so there "nearby" is measured in characters. */
|
|
20
15
|
const LONG_LINE = 1000;
|
|
21
16
|
const NEARBY_CHARS = 200;
|
|
22
17
|
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
18
|
const EXECUTES = patternsOf("eval");
|
|
25
19
|
const EXECUTES_OR_DECODES = patternsOf("eval", "decode");
|
|
26
|
-
/** Every match of `patterns` as an absolute character span, with its line. */
|
|
27
20
|
function spans(lines, patterns, onLines) {
|
|
28
21
|
const found = [];
|
|
29
22
|
let offset = 0;
|
|
@@ -42,11 +35,6 @@ function spans(lines, patterns, onLines) {
|
|
|
42
35
|
return found;
|
|
43
36
|
}
|
|
44
37
|
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
38
|
function nearCall(lines, hit, calls) {
|
|
51
39
|
const patterns = OBFUSCATION_PATTERNS.filter((p) => p.kind === hit.kind);
|
|
52
40
|
const threshold = Math.min(...patterns.map((p) => p.threshold));
|
|
@@ -59,19 +47,9 @@ function nearCall(lines, hit, calls) {
|
|
|
59
47
|
return (marks.filter((m) => gap(m, call) <= NEARBY_CHARS).length >= threshold);
|
|
60
48
|
});
|
|
61
49
|
}
|
|
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
50
|
function assess(lines, hits) {
|
|
70
51
|
const kinds = new Set(hits.map((hit) => hit.kind));
|
|
71
52
|
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
53
|
const markedBy = (hit) => spans(lines, OBFUSCATION_PATTERNS.filter((p) => p.kind === hit.kind).map((p) => p.pattern), new Set(hit.lines));
|
|
76
54
|
const together = hits.some((hit) => FILE_WIDE.has(hit.kind)) ||
|
|
77
55
|
hits.some((hit) => !WEAK.has(hit.kind) &&
|
|
@@ -93,9 +71,6 @@ function fileFinding(path, text, downgradeInstalled) {
|
|
|
93
71
|
if (first === undefined)
|
|
94
72
|
return undefined;
|
|
95
73
|
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
74
|
const severity = downgradeInstalled && isInNodeModules(path) && raw === "block"
|
|
100
75
|
? "warn"
|
|
101
76
|
: raw;
|
|
@@ -108,7 +83,6 @@ function fileFinding(path, text, downgradeInstalled) {
|
|
|
108
83
|
message: `${LEAD[severity]}: ${seen}${raw === "block" ? why : ""}`,
|
|
109
84
|
};
|
|
110
85
|
}
|
|
111
|
-
/** Code made deliberately unreadable — not merely minified. */
|
|
112
86
|
export function obfuscationWith({ downgradeInstalled, }) {
|
|
113
87
|
return (repo) => repo.entries
|
|
114
88
|
.filter((e) => e.kind === "file" &&
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { isInNodeModules } from "./node-modules-present.js";
|
|
2
|
-
/** Code that a JS toolchain, an IDE, or git may execute or load. */
|
|
3
2
|
const CODE = /\.(?:[cm]?[jt]sx?|json|sh|bash|zsh|py|rb|pl|ps1|gyp)$/;
|
|
4
3
|
const LOCKFILES = new Set(["package-lock.json", "npm-shrinkwrap.json"]);
|
|
5
|
-
/** Type declarations are never executed, and lib.dom.d.ts alone is over 1 MB. */
|
|
6
4
|
const DECLARATIONS = /\.d\.[cm]?ts$/;
|
|
7
|
-
/**
|
|
8
|
-
* Safety floor: a code file too large to read was not scanned, so the repo is
|
|
9
|
-
* not clean. Padding a config past the read limit must not hide a payload.
|
|
10
|
-
*/
|
|
11
5
|
export function unscannedWith({ jsonIsData, downgradeInstalled, }) {
|
|
12
6
|
return (repo) => repo.entries
|
|
13
7
|
.filter((entry) => entry.kind === "oversized")
|
|
@@ -20,8 +14,6 @@ export function unscannedWith({ jsonIsData, downgradeInstalled, }) {
|
|
|
20
14
|
.map((entry) => {
|
|
21
15
|
const name = entry.path.slice(entry.path.lastIndexOf("/") + 1);
|
|
22
16
|
const data = jsonIsData && name.endsWith(".json") && name !== "package.json";
|
|
23
|
-
// Bundles over the limit are normal in installed dependencies; the
|
|
24
|
-
// presence of node_modules itself is flagged by repo/node-modules-present.
|
|
25
17
|
const installed = downgradeInstalled && isInNodeModules(entry.path);
|
|
26
18
|
return {
|
|
27
19
|
ruleId: "repo/unscanned",
|
package/dist/hook/hook.js
CHANGED
|
@@ -11,7 +11,6 @@ export class HookInputError extends Error {
|
|
|
11
11
|
function isRecord(value) {
|
|
12
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
13
|
}
|
|
14
|
-
/** The Bash command and directory from a PreToolUse event, or undefined for other tools. */
|
|
15
14
|
function hookCommand(input) {
|
|
16
15
|
let data;
|
|
17
16
|
try {
|
|
@@ -33,14 +32,8 @@ function hookCommand(input) {
|
|
|
33
32
|
throw new HookInputError("the hook input has no Bash command or absolute cwd");
|
|
34
33
|
return { command, cwd: data.cwd };
|
|
35
34
|
}
|
|
36
|
-
/** Text that goes to the shell as one tab-separated field. */
|
|
37
35
|
const SAFE = /^[^\t\n\r\0]+$/;
|
|
38
36
|
const isSafeSpec = (spec) => SAFE.test(spec) && !spec.startsWith("-");
|
|
39
|
-
/**
|
|
40
|
-
* npm's local prefix: the nearest directory, from `dir` up, with a
|
|
41
|
-
* package.json or a node_modules folder (as @npmcli/config does); `dir` itself
|
|
42
|
-
* when there is none. That is where npm installs, not where the agent is.
|
|
43
|
-
*/
|
|
44
37
|
function npmPrefix(dir, exists) {
|
|
45
38
|
for (let at = dir;; at = posix.dirname(at)) {
|
|
46
39
|
if (exists(posix.join(at, "package.json")) ||
|
|
@@ -50,11 +43,6 @@ function npmPrefix(dir, exists) {
|
|
|
50
43
|
return dir;
|
|
51
44
|
}
|
|
52
45
|
}
|
|
53
|
-
/**
|
|
54
|
-
* A registry that pnpm-workspace.yaml (pnpm 10+ settings) points pnpm at, or
|
|
55
|
-
* a setting there that runs the repo's code or trusts another server, or
|
|
56
|
-
* undefined.
|
|
57
|
-
*/
|
|
58
46
|
function workspaceRegistry(root, deps) {
|
|
59
47
|
const file = posix.join(root, "pnpm-workspace.yaml");
|
|
60
48
|
if (!deps.exists(file))
|
|
@@ -82,24 +70,10 @@ function workspaceRegistry(root, deps) {
|
|
|
82
70
|
}
|
|
83
71
|
const PUBLIC_REGISTRY = /^https:\/\/registry\.npmjs\.org\/?$/;
|
|
84
72
|
const REGISTRY_KEY = /(?:^|:)registry$|^replace-registry-host$/;
|
|
85
|
-
/**
|
|
86
|
-
* Keys that load another config croak would not read, change what npm or
|
|
87
|
-
* pnpm runs scripts and git with, or let a proxy or certificate answer for
|
|
88
|
-
* the registry: with them, a warning the user approves would still run the
|
|
89
|
-
* repo's own shell, preload or binary, or packages croak never saw.
|
|
90
|
-
*/
|
|
91
73
|
const RUNNER_KEY = /^(?:userconfig|globalconfig|script-shell|node-options|shell|git|init-module|shell-emulator|global-pnpmfile|pnpmfile|proxy|https?-proxy|ca|cafile|cert|key|strict-ssl)$/;
|
|
92
|
-
/**
|
|
93
|
-
* The same settings as pnpm-workspace.yaml writes them (M6 review): a shell or
|
|
94
|
-
* preload for scripts, a pnpmfile outside the project, and a proxy or
|
|
95
|
-
* certificate setting that lets another server answer for the registry.
|
|
96
|
-
*/
|
|
97
74
|
const WORKSPACE_RUNNER_KEY = /^(?:scriptShell|nodeOptions|shellEmulator|globalPnpmfile|proxy|https?Proxy|ca|cafile|cert|key|strictSsl|userconfig|globalconfig)$/;
|
|
98
|
-
/** Where npm config sends npm somewhere croak does not check (D34), or undefined. */
|
|
99
75
|
function foreignRegistry(prefix, deps) {
|
|
100
76
|
const env = Object.entries(deps.env ?? {});
|
|
101
|
-
// npm exports npm_config_userconfig when it launches a program; the file it
|
|
102
|
-
// names is checked like any other npmrc, rather than refused outright.
|
|
103
77
|
const named = env
|
|
104
78
|
.filter(([key]) => /^npm_config_(?:userconfig|globalconfig)$/i.test(key))
|
|
105
79
|
.map(([, value]) => value ?? "");
|
|
@@ -118,19 +92,16 @@ function foreignRegistry(prefix, deps) {
|
|
|
118
92
|
const line = raw.trim();
|
|
119
93
|
if (line === "" || /^[;#]/.test(line))
|
|
120
94
|
continue;
|
|
121
|
-
// ini sections group keys croak does not read: refuse rather than guess.
|
|
122
95
|
if (line.startsWith("["))
|
|
123
96
|
return `${file} uses [sections]`;
|
|
124
97
|
const eq = line.indexOf("=");
|
|
125
98
|
if (eq === -1)
|
|
126
99
|
continue;
|
|
127
|
-
// Quotes around a key or value are stripped, as npm's ini parser does.
|
|
128
100
|
const unquote = (part) => part.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
129
101
|
const key = unquote(line.slice(0, eq));
|
|
130
102
|
const value = unquote(line.slice(eq + 1));
|
|
131
103
|
if (REGISTRY_KEY.test(key) && !PUBLIC_REGISTRY.test(value))
|
|
132
104
|
return `${file} sets ${key}`;
|
|
133
|
-
// The project's npmrc comes with the repo; the user's own files do not.
|
|
134
105
|
if (file === project && RUNNER_KEY.test(key))
|
|
135
106
|
return `${file} sets ${key}, which changes what npm runs or trusts`;
|
|
136
107
|
}
|
|
@@ -183,7 +154,6 @@ export function hookPlan(input, deps) {
|
|
|
183
154
|
continue;
|
|
184
155
|
}
|
|
185
156
|
const global = intent.kind === "install" && intent.global;
|
|
186
|
-
// A workspace installs at its root (D38): check there, with every member.
|
|
187
157
|
const local = npmPrefix(intent.dir, deps.exists);
|
|
188
158
|
const root = intent.kind === "run" || global
|
|
189
159
|
? undefined
|
|
@@ -207,8 +177,6 @@ export function hookPlan(input, deps) {
|
|
|
207
177
|
if (intent.kind === "ci")
|
|
208
178
|
mode = "ci";
|
|
209
179
|
else if (intent.kind === "run") {
|
|
210
|
-
// npx runs the prefix's own bin when it has one: no download.
|
|
211
|
-
// pnpm dlx always downloads.
|
|
212
180
|
specs = packages.filter((spec) => manager === "pnpm" ||
|
|
213
181
|
spec.includes("@", spec.startsWith("@") ? 1 : 0) ||
|
|
214
182
|
spec.includes("/") ||
|
|
@@ -224,14 +192,12 @@ export function hookPlan(input, deps) {
|
|
|
224
192
|
else if (packages.length > 0)
|
|
225
193
|
mode = "empty";
|
|
226
194
|
else
|
|
227
|
-
continue;
|
|
195
|
+
continue;
|
|
228
196
|
intents.push(intent);
|
|
229
197
|
checks.push({
|
|
230
198
|
id: checks.length + 1,
|
|
231
199
|
manager,
|
|
232
200
|
mode,
|
|
233
|
-
// Where npm stands: croak-install finds the root itself, and checks
|
|
234
|
-
// that this folder is one of its members (M6 review).
|
|
235
201
|
dir: mode === "empty" ? intent.dir : local,
|
|
236
202
|
packages: specs,
|
|
237
203
|
label,
|
|
@@ -239,7 +205,6 @@ export function hookPlan(input, deps) {
|
|
|
239
205
|
}
|
|
240
206
|
return { intents, checks };
|
|
241
207
|
}
|
|
242
|
-
/** One line per check for the shell: check <id> <manager> <mode> <dir> <package>… (tab-separated). */
|
|
243
208
|
export function planLines(plan) {
|
|
244
209
|
return plan.checks
|
|
245
210
|
.map((c) => ["check", String(c.id), c.manager, c.mode, c.dir, ...c.packages].join("\t"))
|
|
@@ -248,9 +213,7 @@ export function planLines(plan) {
|
|
|
248
213
|
}
|
|
249
214
|
const MAX_FINDINGS = 6;
|
|
250
215
|
const DO_NOT_BYPASS = "Do not work around this check (another package manager, a different command, or editing files to install it); tell the user what croak found and let them decide.";
|
|
251
|
-
/** One check's result as a verdict, with the findings worth showing. */
|
|
252
216
|
function judge(result) {
|
|
253
|
-
// croak-install's own lines (and its did-you-mean hints) say more than npm's log.
|
|
254
217
|
const tail = (text) => {
|
|
255
218
|
const lines = text.trim().split("\n");
|
|
256
219
|
const own = lines.filter((l) => /^croak-install:|did you mean/.test(l.trim()));
|
|
@@ -329,12 +292,6 @@ export function hookDecide(intents, results) {
|
|
|
329
292
|
return output("ask", `🐸 croak found things to review before this install runs (not a safe verdict):\n${warned.join("\n")}`);
|
|
330
293
|
return undefined;
|
|
331
294
|
}
|
|
332
|
-
/**
|
|
333
|
-
* `croak hook plan <input.json>` prints the plan: a `plan <json>` line, then
|
|
334
|
-
* the check lines. `croak hook decide <work>` reads that plan and each
|
|
335
|
-
* check's <id>.code / <id>.out / <id>.err from the work directory and prints
|
|
336
|
-
* the hook output, or nothing. Any error is the caller's cue to deny.
|
|
337
|
-
*/
|
|
338
295
|
export function runHook(args, deps) {
|
|
339
296
|
const [step, path] = args;
|
|
340
297
|
if (path === undefined || (step !== "plan" && step !== "decide"))
|
|
@@ -370,7 +327,7 @@ function decideStep(path, deps) {
|
|
|
370
327
|
return deps.readFile(posix.join(path, name));
|
|
371
328
|
}
|
|
372
329
|
catch {
|
|
373
|
-
return undefined;
|
|
330
|
+
return undefined;
|
|
374
331
|
}
|
|
375
332
|
};
|
|
376
333
|
const results = plan.checks.map((check) => {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { shellCommands, } from "../signals/shell.js";
|
|
3
|
-
/** Agent commands can be long (heredocs); the parser is linear. */
|
|
4
3
|
const MAX_COMMAND_LENGTH = 100_000;
|
|
5
4
|
const MAX_DEPTH = 4;
|
|
6
|
-
/** Any of these in a command croak cannot follow makes it unchecked. */
|
|
7
5
|
const MENTIONS = /\b(?:npm|npx|pnpm|pnpx|yarn|bun|bunx|corepack)\b/;
|
|
8
6
|
const PACKAGE_MANAGERS = new Set([
|
|
9
7
|
"npm",
|
|
@@ -15,8 +13,6 @@ const PACKAGE_MANAGERS = new Set([
|
|
|
15
13
|
"bunx",
|
|
16
14
|
"corepack",
|
|
17
15
|
]);
|
|
18
|
-
// ------------------------------------------------------------ npm commands
|
|
19
|
-
/** npm's commands (npm 10/11 cmd-list), for its unique-prefix matching. */
|
|
20
16
|
const NPM_COMMANDS = [
|
|
21
17
|
"access",
|
|
22
18
|
"adduser",
|
|
@@ -138,7 +134,6 @@ const NPM_ALIASES = {
|
|
|
138
134
|
find: "search",
|
|
139
135
|
};
|
|
140
136
|
const NPM_NAMES = [...NPM_COMMANDS, ...Object.keys(NPM_ALIASES)];
|
|
141
|
-
/** Subcommands that download or install packages. */
|
|
142
137
|
const INSTALLING = new Set([
|
|
143
138
|
"install",
|
|
144
139
|
"install-test",
|
|
@@ -150,7 +145,6 @@ const INSTALLING = new Set([
|
|
|
150
145
|
"dedupe",
|
|
151
146
|
"link",
|
|
152
147
|
]);
|
|
153
|
-
/** npm's deref: camelCase to kebab-case, aliases, then a unique prefix. */
|
|
154
148
|
function npmCommand(word) {
|
|
155
149
|
const name = word.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
156
150
|
const exact = NPM_ALIASES[name] ?? (NPM_COMMANDS.includes(name) ? name : undefined);
|
|
@@ -159,7 +153,6 @@ function npmCommand(word) {
|
|
|
159
153
|
const matches = new Set(NPM_NAMES.filter((n) => n.startsWith(name)).map((n) => NPM_ALIASES[n] ?? n));
|
|
160
154
|
return matches.size === 1 ? [...matches][0] : undefined;
|
|
161
155
|
}
|
|
162
|
-
/** npm options that take a value (`--prefix dir`, `-C dir`, `-w pkg`). */
|
|
163
156
|
const VALUE_FLAGS = new Set([
|
|
164
157
|
"prefix",
|
|
165
158
|
"workspace",
|
|
@@ -222,7 +215,6 @@ const VALUE_FLAGS = new Set([
|
|
|
222
215
|
"fetch-retry-mintimeout",
|
|
223
216
|
"fetch-retry-maxtimeout",
|
|
224
217
|
]);
|
|
225
|
-
/** npm options that are flags (no value). */
|
|
226
218
|
const BOOLEAN_FLAGS = new Set([
|
|
227
219
|
"save",
|
|
228
220
|
"save-dev",
|
|
@@ -282,14 +274,12 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
282
274
|
"commit-hooks",
|
|
283
275
|
"shrinkwrap",
|
|
284
276
|
]);
|
|
285
|
-
/** npm's short flags that take a value; every other short flag is a boolean. */
|
|
286
277
|
const SHORT_VALUES = {
|
|
287
278
|
"-C": "prefix",
|
|
288
279
|
"-L": "location",
|
|
289
280
|
"-w": "workspace",
|
|
290
281
|
"-c": "call",
|
|
291
282
|
};
|
|
292
|
-
/** npx (not npm, where -p is --parseable) reads -p as --package. */
|
|
293
283
|
const NPX_SHORT_VALUES = {
|
|
294
284
|
...SHORT_VALUES,
|
|
295
285
|
"-p": "package",
|
|
@@ -316,7 +306,6 @@ function readNpmArgs(words, npx) {
|
|
|
316
306
|
for (let i = 0; i < words.length; i++) {
|
|
317
307
|
const word = words[i] ?? "";
|
|
318
308
|
if (word === "--" || (npx && positional.length > 0)) {
|
|
319
|
-
// npx stops reading its own options at the command it runs.
|
|
320
309
|
positional.push(...words.slice(word === "--" ? i + 1 : i));
|
|
321
310
|
break;
|
|
322
311
|
}
|
|
@@ -349,7 +338,6 @@ function readNpmArgs(words, npx) {
|
|
|
349
338
|
}
|
|
350
339
|
return { flags, positional, ambiguous };
|
|
351
340
|
}
|
|
352
|
-
/** npm init foo → create-foo; @s → @s/create; @s/foo@1 → @s/create-foo@1 (npm's rule). */
|
|
353
341
|
function initializer(spec) {
|
|
354
342
|
const at = spec.indexOf("@", 1);
|
|
355
343
|
const [name, version] = at === -1 ? [spec, ""] : [spec.slice(0, at), spec.slice(at)];
|
|
@@ -392,7 +380,6 @@ function npmIntents(name, args, state, home, depth, raw) {
|
|
|
392
380
|
if (sub === undefined ||
|
|
393
381
|
!INSTALLING.has(sub) ||
|
|
394
382
|
((sub === "init" || sub === "link") && rest.length === 0)) {
|
|
395
|
-
// An unknown option may have swallowed the real subcommand (npm --proxy x install).
|
|
396
383
|
if (ambiguous !== undefined &&
|
|
397
384
|
rest.some((w) => INSTALLING.has(npmCommand(w) ?? "")))
|
|
398
385
|
return unchecked(`croak cannot tell whether ${ambiguous} takes a value`);
|
|
@@ -427,10 +414,8 @@ function npmIntents(name, args, state, home, depth, raw) {
|
|
|
427
414
|
return unchecked(`croak does not check npm ${sub} yet`);
|
|
428
415
|
}
|
|
429
416
|
}
|
|
430
|
-
// ------------------------------------------------------------ pnpm commands
|
|
431
417
|
const PNPM_INSTALL = new Set(["install", "i", "add", "install-test", "it"]);
|
|
432
418
|
const PNPM_UPDATE = new Set(["update", "up", "upgrade"]);
|
|
433
|
-
/** Subcommands that install, or run another pnpm, in ways croak does not plan. */
|
|
434
419
|
const PNPM_UNCHECKED = new Set([
|
|
435
420
|
"dedupe",
|
|
436
421
|
"deploy",
|
|
@@ -444,7 +429,6 @@ const PNPM_UNCHECKED = new Set([
|
|
|
444
429
|
"patch-remove",
|
|
445
430
|
"self-update",
|
|
446
431
|
]);
|
|
447
|
-
/** pnpm options that take a value. */
|
|
448
432
|
const PNPM_VALUE_FLAGS = new Set([
|
|
449
433
|
"-C",
|
|
450
434
|
"--dir",
|
|
@@ -463,7 +447,6 @@ const PNPM_VALUE_FLAGS = new Set([
|
|
|
463
447
|
"--node-linker",
|
|
464
448
|
"--cache-dir",
|
|
465
449
|
]);
|
|
466
|
-
/** pnpm options that are flags. */
|
|
467
450
|
const PNPM_BOOLEAN_FLAGS = new Set([
|
|
468
451
|
"-D",
|
|
469
452
|
"-P",
|
|
@@ -513,8 +496,6 @@ function pnpmIntents(args, state, raw, dlx) {
|
|
|
513
496
|
];
|
|
514
497
|
const flags = new Map();
|
|
515
498
|
const positional = [];
|
|
516
|
-
// An allowlist: pnpm has flags that allow every build, load a pnpmfile or
|
|
517
|
-
// move the lockfile; a flag croak does not know is not guessed at (M6 review).
|
|
518
499
|
let unknown;
|
|
519
500
|
for (let i = 0; i < args.length; i++) {
|
|
520
501
|
const word = args[i] ?? "";
|
|
@@ -568,7 +549,7 @@ function pnpmIntents(args, state, raw, dlx) {
|
|
|
568
549
|
if (unknown !== undefined && installing)
|
|
569
550
|
return unchecked(`croak does not know pnpm's ${unknown}, so it cannot vouch for this install`);
|
|
570
551
|
if (sub === undefined)
|
|
571
|
-
return [];
|
|
552
|
+
return [];
|
|
572
553
|
if (sub === "dlx")
|
|
573
554
|
return pnpmIntents(rest, { dir }, raw, true);
|
|
574
555
|
if (sub === "create")
|
|
@@ -605,8 +586,6 @@ function pnpmIntents(args, state, raw, dlx) {
|
|
|
605
586
|
},
|
|
606
587
|
]);
|
|
607
588
|
}
|
|
608
|
-
// ------------------------------------------------------------ shell commands
|
|
609
|
-
/** Wrappers and the options of theirs that take a value. */
|
|
610
589
|
const WRAPPERS = {
|
|
611
590
|
sudo: new Set([
|
|
612
591
|
"-u",
|
|
@@ -635,7 +614,6 @@ const WRAPPERS = {
|
|
|
635
614
|
setsid: new Set(),
|
|
636
615
|
caffeinate: new Set(["-t", "-w"]),
|
|
637
616
|
};
|
|
638
|
-
/** Shell keywords that start or end a compound command, not a command name. */
|
|
639
617
|
const KEYWORDS = new Set([
|
|
640
618
|
"{",
|
|
641
619
|
"}",
|
|
@@ -657,7 +635,6 @@ const KEYWORDS = new Set([
|
|
|
657
635
|
"function",
|
|
658
636
|
]);
|
|
659
637
|
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
|
|
660
|
-
/** Commands that take a package manager's name as plain text, not to run it. */
|
|
661
638
|
const TEXT_ONLY = new Set([
|
|
662
639
|
"echo",
|
|
663
640
|
"printf",
|
|
@@ -700,7 +677,6 @@ const OTHER_MANAGERS = {
|
|
|
700
677
|
const OTHER_RUNNERS = new Set(["bunx", "corepack"]);
|
|
701
678
|
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
702
679
|
const basename = (word) => word.slice(word.lastIndexOf("/") + 1);
|
|
703
|
-
/** The command's real head and arguments, past keywords, assignments and wrappers. */
|
|
704
680
|
function resolve(words) {
|
|
705
681
|
let rest = words;
|
|
706
682
|
let foreignEnv = false;
|
|
@@ -748,7 +724,6 @@ function resolve(words) {
|
|
|
748
724
|
rest = rest.slice(1);
|
|
749
725
|
}
|
|
750
726
|
}
|
|
751
|
-
/** The script a shell runs with -c (also -lc, -ec, -e -c). */
|
|
752
727
|
function shellScript(args) {
|
|
753
728
|
for (let i = 0; i < args.length; i++) {
|
|
754
729
|
const arg = args[i]?.text ?? "";
|
|
@@ -763,8 +738,6 @@ function intentsOf(commands, state, home, depth, text) {
|
|
|
763
738
|
const found = [];
|
|
764
739
|
for (const command of commands) {
|
|
765
740
|
if (command.group !== undefined) {
|
|
766
|
-
// A subshell's cd does not leak out; a { …; } group's does. Either way,
|
|
767
|
-
// after a cd inside one, the outer directory is no longer certain.
|
|
768
741
|
const inner = { dir: state.dir };
|
|
769
742
|
found.push(...intentsOf(command.group, inner, home, depth, text));
|
|
770
743
|
if (inner.dir !== state.dir)
|
|
@@ -786,7 +759,6 @@ function intentsOf(commands, state, home, depth, text) {
|
|
|
786
759
|
continue;
|
|
787
760
|
}
|
|
788
761
|
if (head.dynamic) {
|
|
789
|
-
// n=npm; $n install x — the command is only known when the shell runs.
|
|
790
762
|
if (MENTIONS.test(text))
|
|
791
763
|
unchecked("the command it runs is only known when the shell runs");
|
|
792
764
|
continue;
|
|
@@ -806,8 +778,6 @@ function intentsOf(commands, state, home, depth, text) {
|
|
|
806
778
|
if (SHELLS.has(name)) {
|
|
807
779
|
const script = shellScript(argWords);
|
|
808
780
|
if (script === undefined) {
|
|
809
|
-
// A script from stdin (a pipe) that mentions a package manager: croak
|
|
810
|
-
// does not see it. A script file is like `npm run x`: a documented gap.
|
|
811
781
|
const fromStdin = !argWords.some((w) => !w.text.startsWith("-"));
|
|
812
782
|
if (fromStdin && MENTIONS.test(text))
|
|
813
783
|
unchecked(`${name} runs a script from stdin croak cannot see`);
|
|
@@ -857,15 +827,12 @@ function intentsOf(commands, state, home, depth, text) {
|
|
|
857
827
|
unchecked(`croak checks npm and pnpm; ${name} is not supported yet`);
|
|
858
828
|
continue;
|
|
859
829
|
}
|
|
860
|
-
// xargs npm i, find -exec npm i, watch npm i, docker run … npm i: a
|
|
861
|
-
// package manager run by another command, with arguments croak cannot follow.
|
|
862
830
|
if (!TEXT_ONLY.has(name) &&
|
|
863
831
|
argWords.some((w) => PACKAGE_MANAGERS.has(basename(w.text))))
|
|
864
832
|
unchecked(`it runs a package manager through ${name}`);
|
|
865
833
|
}
|
|
866
834
|
return found;
|
|
867
835
|
}
|
|
868
|
-
/** Heredoc bodies are data for most commands, and a script croak cannot see for a shell. */
|
|
869
836
|
function stripHeredocs(command) {
|
|
870
837
|
const lines = command.split("\n");
|
|
871
838
|
const out = [];
|
|
@@ -889,7 +856,6 @@ function stripHeredocs(command) {
|
|
|
889
856
|
}
|
|
890
857
|
return { text: out.join("\n"), shellHeredoc };
|
|
891
858
|
}
|
|
892
|
-
/** Settings in the same command that move npm to another registry. */
|
|
893
859
|
const CONFIG_CHANGE = /\b(?:npm|pnpm)\s+(?:config\s+)?(?:set|edit)\b|\bexport\s+(?:npm|pnpm)_config_|>>?\s*\S*(?:\.npmrc|pnpm-workspace\.yaml)\b/i;
|
|
894
860
|
function intentsFor(command, state, home, depth) {
|
|
895
861
|
const { text, shellHeredoc } = stripHeredocs(command);
|
|
@@ -921,7 +887,6 @@ function intentsFor(command, state, home, depth) {
|
|
|
921
887
|
];
|
|
922
888
|
return found;
|
|
923
889
|
}
|
|
924
|
-
/** Install intents in `command`, run from `cwd` (absolute) by a user whose home is `home`. */
|
|
925
890
|
export function installIntents(command, cwd, home) {
|
|
926
891
|
return intentsFor(command, { dir: cwd }, home, 0);
|
|
927
892
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
/** Only sha512 is strong enough to stand in for the tarball's content. */
|
|
5
4
|
const cacheable = (integrity) => integrity !== undefined &&
|
|
6
5
|
/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity.trim());
|
|
7
6
|
const SEVERITIES = new Set(["info", "warn", "block"]);
|
|
@@ -16,12 +15,9 @@ function isFinding(value) {
|
|
|
16
15
|
SEVERITIES.has(f.severity) &&
|
|
17
16
|
(f.line === undefined || typeof f.line === "number"));
|
|
18
17
|
}
|
|
19
|
-
/** Errors from the filesystem (not bugs): the cache degrades to a miss. */
|
|
20
18
|
const isFsError = (error) => error instanceof Error &&
|
|
21
19
|
typeof error.code === "string";
|
|
22
20
|
export function fileContentCache(dir, fingerprint) {
|
|
23
|
-
// One file per tarball and label: content and provenance findings for the
|
|
24
|
-
// same tarball do not overwrite each other.
|
|
25
21
|
const path = (integrity, label) => join(dir, `${createHash("sha256").update(`${fingerprint}\n${integrity.trim()}\n${label}`).digest("hex")}.json`);
|
|
26
22
|
return {
|
|
27
23
|
get(integrity, label) {
|
|
@@ -39,7 +35,6 @@ export function fileContentCache(dir, fingerprint) {
|
|
|
39
35
|
if (typeof data !== "object" || data === null)
|
|
40
36
|
return undefined;
|
|
41
37
|
const entry = data;
|
|
42
|
-
// The same tarball under another name or version is not reused.
|
|
43
38
|
if (entry.label !== label ||
|
|
44
39
|
!Array.isArray(entry.findings) ||
|
|
45
40
|
!entry.findings.every(isFinding))
|
|
@@ -53,7 +48,6 @@ export function fileContentCache(dir, fingerprint) {
|
|
|
53
48
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
54
49
|
try {
|
|
55
50
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
56
|
-
// Write then rename, so a concurrent reader never sees half a file.
|
|
57
51
|
writeFileSync(tmp, JSON.stringify({ label, findings }), {
|
|
58
52
|
mode: 0o600,
|
|
59
53
|
});
|
|
@@ -66,7 +60,6 @@ export function fileContentCache(dir, fingerprint) {
|
|
|
66
60
|
rmSync(tmp, { force: true });
|
|
67
61
|
}
|
|
68
62
|
catch (cleanup) {
|
|
69
|
-
// Best effort: a stray .tmp file is never read back.
|
|
70
63
|
if (!isFsError(cleanup))
|
|
71
64
|
throw cleanup;
|
|
72
65
|
}
|
|
@@ -74,7 +67,6 @@ export function fileContentCache(dir, fingerprint) {
|
|
|
74
67
|
},
|
|
75
68
|
};
|
|
76
69
|
}
|
|
77
|
-
/** A hash of croak's own code (the src/ or dist/ tree this module runs from). */
|
|
78
70
|
export function codeFingerprint(root) {
|
|
79
71
|
const hash = createHash("sha256");
|
|
80
72
|
const walk = (dir) => {
|
package/dist/install/content.js
CHANGED
|
@@ -3,11 +3,6 @@ import { DEPENDENCY_INSTALL_SCRIPTS, lifecycleScriptWith, } from "../detectors/l
|
|
|
3
3
|
import { obfuscationWith } from "../detectors/obfuscation.js";
|
|
4
4
|
import { unscannedWith } from "../detectors/unscanned.js";
|
|
5
5
|
import { isSuspiciousInDependency } from "../signals/code.js";
|
|
6
|
-
/**
|
|
7
|
-
* The repo detectors that make sense for a package's own files. Not
|
|
8
|
-
* ide-autorun or git-config (they do nothing inside node_modules) and not
|
|
9
|
-
* node-modules-present (bundled dependencies are normal in a tarball).
|
|
10
|
-
*/
|
|
11
6
|
const DEPENDENCY_DETECTORS = [
|
|
12
7
|
unscannedWith({ jsonIsData: true, downgradeInstalled: false }),
|
|
13
8
|
lifecycleScriptWith({
|
|
@@ -15,19 +10,14 @@ const DEPENDENCY_DETECTORS = [
|
|
|
15
10
|
scripts: DEPENDENCY_INSTALL_SCRIPTS,
|
|
16
11
|
manifests: "package",
|
|
17
12
|
}),
|
|
18
|
-
// Bundled dependencies (node_modules inside the tarball) are code npm
|
|
19
|
-
// installs and runs: judged like the package itself, never downgraded.
|
|
20
13
|
hiddenCodeWith({ downgradeInstalled: false }),
|
|
21
14
|
obfuscationWith({ downgradeInstalled: false }),
|
|
22
15
|
];
|
|
23
|
-
/** Findings for one package's contents, with paths prefixed by `name@version/`. */
|
|
24
16
|
export function contentFindings(label, snapshot) {
|
|
25
17
|
const findings = DEPENDENCY_DETECTORS.flatMap((detect) => detect(snapshot)).map((finding) => ({
|
|
26
18
|
...finding,
|
|
27
19
|
file: `${label}/${finding.file}`,
|
|
28
20
|
}));
|
|
29
|
-
// Context, not a warning: prebuilt binaries are how esbuild, swc, sharp
|
|
30
|
-
// and TypeScript 7 ship. What runs them at install time is checked above.
|
|
31
21
|
const natives = snapshot.entries.filter((e) => e.path.endsWith(".node"));
|
|
32
22
|
const first = natives[0];
|
|
33
23
|
if (first !== undefined) {
|
|
@@ -3,7 +3,6 @@ const label = (change) => `${change.entry.name}@${change.entry.version ?? "?"}`;
|
|
|
3
3
|
const what = (change) => change.kind === "changed"
|
|
4
4
|
? `updates ${change.entry.name} ${change.previousVersion} → ${change.entry.version}`
|
|
5
5
|
: "is new";
|
|
6
|
-
/** npm records hasInstallScript for packages with preinstall/install/postinstall (or binding.gyp). */
|
|
7
6
|
export const installScripts = (changes) => changes
|
|
8
7
|
.filter((c) => c.entry.hasInstallScript)
|
|
9
8
|
.map((c) => ({
|
|
@@ -12,12 +11,9 @@ export const installScripts = (changes) => changes
|
|
|
12
11
|
file: label(c),
|
|
13
12
|
message: `${what(c)} and runs code at install time (preinstall/install/postinstall or a native build)`,
|
|
14
13
|
}));
|
|
15
|
-
/** Why a package is not a registry download, or undefined when it is one (registryTarball). */
|
|
16
14
|
function sourceProblem(resolved, name, version) {
|
|
17
15
|
const tarball = registryTarball(resolved);
|
|
18
16
|
if (tarball !== undefined) {
|
|
19
|
-
// The lockfile's name decides which registry facts croak reads: the URL
|
|
20
|
-
// must be that package's own tarball, not another package's.
|
|
21
17
|
const base = name.slice(name.lastIndexOf("/") + 1);
|
|
22
18
|
return decodeURIComponent(tarball.pathname) ===
|
|
23
19
|
`/${name}/-/${base}-${version ?? ""}.tgz`
|
|
@@ -37,31 +33,36 @@ function sourceProblem(resolved, name, version) {
|
|
|
37
33
|
return `is downloaded over ${url.protocol.replace(":", "")}, not https`;
|
|
38
34
|
return `comes from ${url.host}${url.username === "" ? "" : " with credentials in the URL"}, not ${REGISTRY}`;
|
|
39
35
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
36
|
+
export const source = (changes) => {
|
|
37
|
+
const folders = new Set(changes.map((c) => c.entry.path));
|
|
38
|
+
return changes.flatMap((c) => {
|
|
39
|
+
if (c.entry.link &&
|
|
40
|
+
c.entry.resolved !== undefined &&
|
|
41
|
+
folders.has(c.entry.resolved))
|
|
42
|
+
return [];
|
|
43
|
+
if (c.entry.link) {
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
ruleId: "install/source",
|
|
47
|
+
severity: "info",
|
|
48
|
+
file: label(c),
|
|
49
|
+
message: "is a workspace link, not a download",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
const problem = sourceProblem(c.entry.resolved, c.entry.name, c.entry.version);
|
|
54
|
+
return problem === undefined
|
|
55
|
+
? []
|
|
56
|
+
: [
|
|
57
|
+
{
|
|
58
|
+
ruleId: "install/source",
|
|
59
|
+
severity: "warn",
|
|
60
|
+
file: label(c),
|
|
61
|
+
message: `${what(c)} and ${problem}`,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
});
|
|
65
|
+
};
|
|
65
66
|
export const integrity = (changes) => changes
|
|
66
67
|
.filter((c) => !c.entry.link &&
|
|
67
68
|
c.entry.resolved !== undefined &&
|