@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
@@ -1,25 +1,8 @@
1
- /**
2
- * Facts about code placed where a human reading the file will not see it:
3
- *
4
- * - offscreen — after a long run of spaces or tabs on the same line, far past
5
- * the right edge of the editor (e.g. a payload after `module.exports = {};`)
6
- * - vertical — after hundreds of blank lines, far below the visible code
7
- * - bidi — Unicode bidirectional controls that make the displayed text
8
- * differ from what runs (Trojan Source, CVE-2021-42574)
9
- *
10
- * No verdicts here; detectors weigh these facts with code signals. Character
11
- * scanning only, no backtracking regexes.
12
- */
13
- /** Whitespace run, in characters, that pushes code offscreen. */
14
1
  export const MIN_GAP = 100;
15
- /** Consecutive blank lines that push code below the fold. */
16
2
  export const MIN_BLANK_LINES = 150;
17
- // Any Unicode whitespace hides code, not only spaces and tabs (NBSP, U+3000, …).
18
3
  const isSpace = (ch) => /\s/.test(ch);
19
4
  const BLANK = /^\s*$/;
20
- /** JS and editors break lines at \r\n, \n, a lone \r, U+2028, and U+2029. */
21
5
  const LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/;
22
- /** U+202A–U+202E (embeddings/overrides) and U+2066–U+2069 (isolates). */
23
6
  function isBidiControl(code) {
24
7
  return ((code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069));
25
8
  }
@@ -1,9 +1,3 @@
1
- /**
2
- * JSON with comments and trailing commas, as VS Code reads `.vscode/*.json`.
3
- * One linear pass turns JSONC into plain JSON (comments become spaces,
4
- * trailing commas are dropped), then JSON.parse does the rest. Nesting depth
5
- * is checked during the pass so hostile input cannot recurse deeply.
6
- */
7
1
  export const MAX_JSONC_DEPTH = 64;
8
2
  export class JsoncError extends Error {
9
3
  constructor(message) {
@@ -11,7 +5,6 @@ export class JsoncError extends Error {
11
5
  this.name = "JsoncError";
12
6
  }
13
7
  }
14
- /** Index of the next character that is not whitespace, or text.length. */
15
8
  function nextSignificant(text, from) {
16
9
  let i = from;
17
10
  while (i < text.length && /\s/.test(text[i] ?? ""))
@@ -56,8 +49,6 @@ function toJson(text) {
56
49
  out += ch;
57
50
  }
58
51
  else if (ch === ",") {
59
- // Drop a comma whose next significant token (comments already being
60
- // skipped) closes the container.
61
52
  out += ",";
62
53
  const rest = toJsonPeek(text, i + 1);
63
54
  if (rest === "}" || rest === "]")
@@ -69,7 +60,6 @@ function toJson(text) {
69
60
  }
70
61
  return out;
71
62
  }
72
- /** The next significant character after `from`, skipping whitespace and comments. */
73
63
  function toJsonPeek(text, from) {
74
64
  let i = nextSignificant(text, from);
75
65
  while (i < text.length &&
@@ -1,10 +1,3 @@
1
- /**
2
- * Facts that code was deliberately made unreadable — not merely minified.
3
- * Minifiers shorten names and drop whitespace but keep strings and property
4
- * names readable; obfuscators encode strings, rename to `_0x…`, rotate string
5
- * arrays, and reach `eval` through computed names. No verdicts here.
6
- * Patterns are linear (checked by `pnpm redos`).
7
- */
8
1
  export const OBFUSCATION_PATTERNS = [
9
2
  {
10
3
  kind: "escapes",
@@ -30,9 +23,6 @@ export const OBFUSCATION_PATTERNS = [
30
23
  {
31
24
  kind: "dynamic-call",
32
25
  label: "computed global call",
33
- // globalThis[expr](…) with a non-literal key (one level of nested [] allowed),
34
- // but not a well-known symbol: self[Symbol.asyncIterator]() is how
35
- // iterators are called (the Anthropic SDK's streaming code)…
36
26
  pattern: /\b(?:globalThis|window|global|self)\s*\[\s*(?!Symbol\s*\.)[^\]"'`\s][^\]]{0,100}\]{1,2}\s*\(/,
37
27
  threshold: 1,
38
28
  scope: "line",
@@ -40,7 +30,6 @@ export const OBFUSCATION_PATTERNS = [
40
30
  {
41
31
  kind: "dynamic-call",
42
32
  label: "string-keyed eval",
43
- // …or a literal key that names eval or Function.
44
33
  pattern: /\b(?:globalThis|window|global|self)\s*\[\s*["'`](?:eval|Function)["'`]\s*\]/,
45
34
  threshold: 1,
46
35
  scope: "line",
@@ -1,12 +1,3 @@
1
- /**
2
- * A small, defensive shell parser for package.json scripts. It understands
3
- * quoting, pipes, command lists, `$(...)`, backticks, `<(...)`, subshells,
4
- * redirects, and `sh -c '...'` — enough to see what a script really runs,
5
- * without one fragile regex. It never executes anything.
6
- *
7
- * Linear scanning only; input length and nesting depth are capped so hostile
8
- * scripts cannot make it hang.
9
- */
10
1
  export const MAX_SCRIPT_LENGTH = 10_000;
11
2
  const MAX_DEPTH = 8;
12
3
  const DOWNLOADERS = new Set([
@@ -24,7 +15,6 @@ const DOWNLOADERS = new Set([
24
15
  "certutil",
25
16
  "bitsadmin",
26
17
  ]);
27
- /** Commands that execute code they are given via stdin, arguments, or files. */
28
18
  const INTERPRETERS = new Set([
29
19
  "sh",
30
20
  "bash",
@@ -75,7 +65,6 @@ const WRAPPERS = new Set([
75
65
  "setsid",
76
66
  "ionice",
77
67
  ]);
78
- /** Shell keywords that start or end a compound command, not a command name. */
79
68
  const RESERVED = new Set([
80
69
  "{",
81
70
  "}",
@@ -96,7 +85,6 @@ const RESERVED = new Set([
96
85
  ]);
97
86
  const PACKAGE_RUNNERS = new Set(["npm", "pnpm", "yarn"]);
98
87
  const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
99
- /** Package-manager subcommands that are not package.json script names. */
100
88
  const PM_BUILTINS = new Set([
101
89
  "install",
102
90
  "i",
@@ -129,9 +117,7 @@ const PM_BUILTINS = new Set([
129
117
  "set",
130
118
  "help",
131
119
  ]);
132
- /** npm runs these scripts without `run`. */
133
120
  const NPM_SCRIPT_ALIASES = new Set(["test", "start", "stop", "restart"]);
134
- /** The package.json script a package-manager command runs, if any. */
135
121
  function scriptName(name, args) {
136
122
  if (!PACKAGE_RUNNERS.has(name) && name !== "bun")
137
123
  return undefined;
@@ -147,7 +133,6 @@ function scriptName(name, args) {
147
133
  }
148
134
  class ParseError extends Error {
149
135
  }
150
- /** Index just past the `close` that balances the opener before `start`. */
151
136
  function findClose(input, start, open, close) {
152
137
  let depth = 1;
153
138
  for (let i = start; i < input.length; i++) {
@@ -260,7 +245,6 @@ function parse(input, depth) {
260
245
  }
261
246
  else if (ch === '"') {
262
247
  const end = findDoubleQuoteEnd(input, i + 1);
263
- // Inside double quotes, $(...) and backticks still run.
264
248
  const body = input.slice(i + 1, end);
265
249
  for (let j = 0; j < body.length; j++) {
266
250
  if (body[j] === "\\") {
@@ -300,7 +284,6 @@ function parse(input, depth) {
300
284
  i = close;
301
285
  }
302
286
  else if (ch === "#" && !inWord) {
303
- // A comment runs to the end of the line, quotes and all.
304
287
  const newline = input.indexOf("\n", i);
305
288
  i = (newline === -1 ? input.length : newline) - 1;
306
289
  }
@@ -308,7 +291,6 @@ function parse(input, depth) {
308
291
  !inWord &&
309
292
  words.length === 0 &&
310
293
  (next === " " || next === "\t" || next === "\n")) {
311
- // { …; } groups commands like a subshell does.
312
294
  const close = findClose(input, i + 1, "{", "}");
313
295
  subshell = parse(input.slice(i + 1, close - 1), depth + 1);
314
296
  i = close - 1;
@@ -335,7 +317,6 @@ function parse(input, depth) {
335
317
  endPipeline();
336
318
  }
337
319
  else if (ch === ">" || ch === "<") {
338
- // A leading fd number (2>) belongs to the redirect, not to a word.
339
320
  if (inWord && /^\d+$/.test(text) && subs.length === 0) {
340
321
  text = "";
341
322
  inWord = false;
@@ -359,14 +340,12 @@ function parse(input, depth) {
359
340
  endPipeline();
360
341
  return script;
361
342
  }
362
- // ---------------------------------------------------------------- analysis
363
343
  const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
364
344
  const normalizeName = (word) => basename(word)
365
345
  .toLowerCase()
366
346
  .replace(/\.exe$/, "");
367
347
  const stripDot = (path) => path.startsWith("./") ? path.slice(2) : path;
368
348
  const urlFileName = (url) => basename(url.split(/[?#]/)[0] ?? "");
369
- /** The real command after env assignments and wrappers, with its arguments. */
370
349
  const NPX_LIKE = new Set(["npx", "pnpx", "bunx"]);
371
350
  const WORKSPACE_FLAGS = new Set([
372
351
  "-r",
@@ -379,7 +358,6 @@ const WORKSPACE_FLAGS = new Set([
379
358
  "-ws",
380
359
  "--if-present",
381
360
  ]);
382
- /** The real command after env assignments and wrappers, with its arguments. */
383
361
  function resolveCommand(command) {
384
362
  const words = [...command.words];
385
363
  const modifiers = [];
@@ -400,7 +378,6 @@ function resolveCommand(command) {
400
378
  if (WRAPPERS.has(name)) {
401
379
  words.shift();
402
380
  const flags = [];
403
- // Skip wrapper flags (sudo -u user, nice -n 5, npx --yes).
404
381
  while (words[0]?.text.startsWith("-")) {
405
382
  const flag = words.shift()?.text ?? "";
406
383
  flags.push(flag);
@@ -409,10 +386,8 @@ function resolveCommand(command) {
409
386
  words.shift();
410
387
  }
411
388
  }
412
- // timeout's first operand is the duration, not the command.
413
389
  if (name === "timeout" && /^\d/.test(words[0]?.text ?? ""))
414
390
  words.shift();
415
- // A bare npx runs a local bin; npx with flags or any other wrapper changes what runs.
416
391
  if (!NPX_LIKE.has(name) || flags.length > 0)
417
392
  modifiers.push(`runs through ${name}${flags.length > 0 ? ` ${flags.join(" ")}` : ""}`);
418
393
  continue;
@@ -459,7 +434,6 @@ function addLocal(acc, path) {
459
434
  if (!acc.localFiles.includes(clean))
460
435
  acc.localFiles.push(clean);
461
436
  }
462
- /** Files a downloader writes, so a later command that runs them is caught. */
463
437
  function downloadTargets(name, args, redirects) {
464
438
  const targets = [];
465
439
  const url = args.find((a) => /^[a-z]+:\/\//i.test(a.text))?.text ?? "";
@@ -468,7 +442,6 @@ function downloadTargets(name, args, redirects) {
468
442
  const flag = args[i]?.text ?? "";
469
443
  const value = args[i + 1]?.text;
470
444
  if (name === "curl") {
471
- // -o FILE, also inside a cluster such as -sSLo FILE
472
445
  if ((flag === "--output" || /^-[a-zA-Z]*o$/.test(flag)) &&
473
446
  value !== undefined)
474
447
  targets.push(value);
@@ -476,7 +449,6 @@ function downloadTargets(name, args, redirects) {
476
449
  targets.push(urlFileName(url));
477
450
  }
478
451
  if (name === "wget") {
479
- // -O FILE, -qO FILE, -qO- (stdout), --output-document=FILE
480
452
  const cluster = /^-[a-zA-Z]*O(.*)$/.exec(flag);
481
453
  const long = flag.startsWith("--output-document")
482
454
  ? (flag.split("=")[1] ?? value)
@@ -522,7 +494,6 @@ function walkCommand(command, acc, depth) {
522
494
  const script = scriptName(name, args);
523
495
  if (script !== undefined)
524
496
  acc.runScripts.push(script);
525
- // Running a file the script downloaded earlier.
526
497
  if (acc.downloaded.has(stripDot(raw)) ||
527
498
  args.some((a) => INTERPRETERS.has(name) && acc.downloaded.has(stripDot(a.text)))) {
528
499
  acc.downloadExecute = true;
@@ -535,7 +506,6 @@ function walkCommand(command, acc, depth) {
535
506
  }
536
507
  const interpreter = INTERPRETERS.has(name);
537
508
  if (interpreter) {
538
- // bash <(curl …), sh -c "$(curl …)", eval "$(curl …)"
539
509
  const fed = [...args, ...command.redirects.map((r) => r.target)].some((w) => w.subs.some(hasNetwork));
540
510
  if (fed)
541
511
  acc.downloadExecute = true;
@@ -543,7 +513,6 @@ function walkCommand(command, acc, depth) {
543
513
  if (raw.includes("/") && !interpreter && !downloader)
544
514
  addLocal(acc, raw);
545
515
  if (SHELLS.has(name)) {
546
- // -c, also inside a cluster such as -ec or -xc
547
516
  const c = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a.text));
548
517
  const body = c === -1 ? undefined : args[c + 1];
549
518
  if (body !== undefined) {
@@ -595,7 +564,6 @@ function walkCommand(command, acc, depth) {
595
564
  }
596
565
  return { downloader, interpreter };
597
566
  }
598
- /** A command with a process substitution whose first command is an interpreter, as in `tee >(sh)`. */
599
567
  function feedsInterpreter(command) {
600
568
  return command.words.some((word) => word.subs.some((sub) => {
601
569
  const first = sub[0]?.[0];
@@ -608,7 +576,6 @@ function walkScript(script, acc, depth) {
608
576
  let downloaded = false;
609
577
  for (const command of pipeline) {
610
578
  const { downloader, interpreter } = walkCommand(command, acc, depth);
611
- // curl … | sh, curl … | tee f | sh, curl … | tee >(sh)
612
579
  if ((interpreter || feedsInterpreter(command)) && downloaded)
613
580
  acc.downloadExecute = true;
614
581
  if (downloader)
@@ -678,11 +645,6 @@ export function analyzeShell(command) {
678
645
  unparsable: false,
679
646
  };
680
647
  }
681
- /**
682
- * Commands in the order the shell would run them (pipelines and lists
683
- * flattened), for callers that need argument-level detail, such as the agent
684
- * hook. Undefined when the text cannot be parsed or is longer than maxLength.
685
- */
686
648
  export function shellCommands(input, maxLength = MAX_SCRIPT_LENGTH) {
687
649
  if (input.length > maxLength)
688
650
  return undefined;
@@ -695,8 +657,6 @@ export function shellCommands(input, maxLength = MAX_SCRIPT_LENGTH) {
695
657
  return undefined;
696
658
  throw error;
697
659
  }
698
- // $(…) and backticks run before their command, in a subshell: each comes
699
- // first as a group of its own, so an install inside one is seen.
700
660
  const flatten = (s) => s.flatMap((pipeline) => pipeline.flatMap((command) => {
701
661
  const subs = [
702
662
  ...command.words.flatMap((w) => w.subs),
@@ -1,2 +1 @@
1
- /** npm, VS Code, and most JSON readers ignore a leading UTF-8 byte-order mark. */
2
1
  export const stripBom = (text) => text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
package/dist/ui.js CHANGED
@@ -1,4 +1,3 @@
1
- // Every icon is a single wide (2-column) emoji, so text after it stays aligned.
2
1
  export const VERDICT_ICON = {
3
2
  allow: "🟢",
4
3
  warn: "🟡",
package/dist/verdict.js CHANGED
@@ -11,7 +11,6 @@ const SEVERITY_VERDICT = {
11
11
  warn: "warn",
12
12
  block: "block",
13
13
  };
14
- /** The verdict is the most severe finding; info findings and no findings mean allow. */
15
14
  export function aggregate(findings) {
16
15
  return findings.reduce((worst, finding) => {
17
16
  const verdict = SEVERITY_VERDICT[finding.severity];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vola-trebla/croak",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Croak before you install: a pre-execution gate for package installs, built for AI agents",
5
5
  "keywords": [
6
6
  "security",
@@ -15,8 +15,10 @@ here=$(dirname "$self")
15
15
  cli="$here/../dist/cli.js"
16
16
  install="$here/croak-install.sh"
17
17
  [ -f "$cli" ] || deny "$cli not found (run pnpm build in the croak repo)"
18
- # Below Node 24.2 the CLI's entry point does not run and exits 0 with no
19
- # output: every step would "succeed" silently, and every install would pass.
18
+ # croak needs Node 24.2 or later (package.json engines). On a Node whose
19
+ # entry point check does not run, the CLI exits 0 with no output, and every
20
+ # step would "succeed" silently; on others, behaviour croak was never tested
21
+ # with would decide. Either way: deny instead.
20
22
  node -e 'const [a, b] = process.versions.node.split(".").map(Number); process.exit(a > 24 || (a === 24 && b >= 2) ? 0 : 1)' ||
21
23
  deny "croak needs Node 24.2 or later (found $(node --version 2>/dev/null || echo none))"
22
24
 
@@ -229,12 +229,20 @@ if ! isolated "$@" >"$work/plan.log" 2>&1; then
229
229
  tail -n 15 "$work/plan.log" >&2
230
230
  exit 3
231
231
  fi
232
- if [ ! -f "$work/plan/$lockname" ] && [ -n "$member" ] && [ -f "$work/plan/$member/$lockname" ]; then
233
- # The package manager planned the folder as its own project: the workspace
234
- # does not list it, and the real install runs there too. That plan lacked
235
- # the folder's own lockfile and sanitized manifest, so plan the folder again
236
- # as a project of its own, exactly as outside any workspace (D48). croak
237
- # plan still refuses a --member the lockfile does not list (M6 review).
232
+ # The copy holds package.json files only, so a lockfile in the member's folder
233
+ # means the package manager planned that folder apart from the workspace, as
234
+ # the real install will. Decide by that, never by the root lockfile, which the
235
+ # package manager then left as it was and the repository may have forged (D52).
236
+ if [ -n "$member" ] && [ -f "$work/plan/$member/$lockname" ]; then
237
+ if [ "$manager" != npm ]; then
238
+ # pnpm writes one for a real member too (sharedWorkspaceLockfile: false);
239
+ # planning it alone would leave the rest of the workspace out.
240
+ echo "croak-install: $manager planned $member apart from its workspace, so this install was not checked" >&2
241
+ exit 3
242
+ fi
243
+ # npm installs a folder the workspace does not list as a project of its own.
244
+ # That plan lacked the folder's own lockfile and sanitized manifest, so plan
245
+ # the folder again, exactly as outside any workspace (D48).
238
246
  cd "$start"
239
247
  if [ "$manager" = npm ] && [ -f npm-shrinkwrap.json ]; then
240
248
  echo "croak-install: npm-shrinkwrap.json is not supported yet, so this install was not checked" >&2