@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.
Files changed (56) hide show
  1. package/dist/cli.js +1 -27
  2. package/dist/crypto/der.js +0 -13
  3. package/dist/crypto/fields.js +0 -11
  4. package/dist/crypto/registry-signature.js +0 -12
  5. package/dist/crypto/rekor.js +0 -13
  6. package/dist/crypto/sigstore.js +0 -9
  7. package/dist/detectors/git-config.js +0 -4
  8. package/dist/detectors/hidden-code.js +0 -7
  9. package/dist/detectors/ide-autorun.js +0 -9
  10. package/dist/detectors/index.js +0 -1
  11. package/dist/detectors/lifecycle-script.js +2 -41
  12. package/dist/detectors/node-modules-present.js +0 -7
  13. package/dist/detectors/obfuscation.js +0 -26
  14. package/dist/detectors/unscanned.js +0 -8
  15. package/dist/hook/hook.js +2 -45
  16. package/dist/hook/install-commands.js +1 -36
  17. package/dist/install/content-cache.js +0 -8
  18. package/dist/install/content.js +0 -10
  19. package/dist/install/detectors.js +30 -29
  20. package/dist/install/installed.js +3 -19
  21. package/dist/install/lockfile.js +0 -7
  22. package/dist/install/metadata.js +5 -32
  23. package/dist/install/plan.js +0 -10
  24. package/dist/install/pnpm-builds.js +0 -13
  25. package/dist/install/pnpm-lockfile.js +0 -14
  26. package/dist/install/popular.js +0 -5
  27. package/dist/install/project.js +0 -20
  28. package/dist/install/provenance.js +0 -21
  29. package/dist/install/registry.js +0 -33
  30. package/dist/install/sanitize.js +0 -14
  31. package/dist/install/scan.js +0 -31
  32. package/dist/install/signature.js +0 -7
  33. package/dist/install/tar.js +1 -8
  34. package/dist/install/workspace.js +0 -1
  35. package/dist/install/yaml.js +0 -26
  36. package/dist/llm/client.js +0 -8
  37. package/dist/llm/cost.js +0 -2
  38. package/dist/llm/diff.js +0 -14
  39. package/dist/llm/plan-step.js +0 -5
  40. package/dist/llm/select.js +0 -6
  41. package/dist/llm/triage.js +0 -10
  42. package/dist/repo-report.js +0 -11
  43. package/dist/repo-snapshot.js +0 -17
  44. package/dist/scan.js +0 -5
  45. package/dist/signals/code.js +0 -29
  46. package/dist/signals/git-config.js +0 -7
  47. package/dist/signals/hidden.js +0 -17
  48. package/dist/signals/jsonc.js +0 -10
  49. package/dist/signals/obfuscation.js +0 -11
  50. package/dist/signals/shell.js +0 -40
  51. package/dist/signals/text.js +0 -1
  52. package/dist/ui.js +0 -1
  53. package/dist/verdict.js +0 -1
  54. package/package.json +1 -1
  55. package/scripts/croak-hook.sh +4 -2
  56. 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; // npm install without a package.json installs nothing
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; // a missing result is judged as a check that did not run
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 []; // bare pnpm prints its help
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) => {
@@ -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
- /** Packages that do not come from the public npm registry over https. */
41
- export const source = (changes) => changes.flatMap((c) => {
42
- if (c.entry.link) {
43
- return [
44
- {
45
- ruleId: "install/source",
46
- severity: "info",
47
- file: label(c),
48
- message: "is a workspace link, not a download",
49
- },
50
- ];
51
- }
52
- const problem = sourceProblem(c.entry.resolved, c.entry.name, c.entry.version);
53
- return problem === undefined
54
- ? []
55
- : [
56
- {
57
- ruleId: "install/source",
58
- severity: "warn",
59
- file: label(c),
60
- message: `${what(c)} and ${problem}`,
61
- },
62
- ];
63
- });
64
- /** A downloaded package without an integrity hash is not verified by npm. */
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 &&