@vola-trebla/croak 0.1.0 → 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.
Files changed (54) 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 +0 -6
  20. package/dist/install/installed.js +3 -19
  21. package/dist/install/lockfile.js +0 -7
  22. package/dist/install/metadata.js +1 -31
  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
@@ -3,12 +3,6 @@ const sha512Of = (integrity) => integrity
3
3
  ?.trim()
4
4
  .split(/\s+/)
5
5
  .find((hash) => hash.startsWith("sha512-"));
6
- /**
7
- * The registry's signature over this version (M7, D42). Broken blocks: a
8
- * known key's signature that does not verify, or a lockfile hash that is not
9
- * the one the registry signed. Missing or unverifiable warns: npm signs every
10
- * version, but a key rotation croak missed must not block every install.
11
- */
12
6
  export function signatureFindings(change, facts, keys = NPM_REGISTRY_KEYS) {
13
7
  const { entry } = change;
14
8
  const version = entry.version ?? "";
@@ -46,7 +40,6 @@ export function signatureFindings(change, facts, keys = NPM_REGISTRY_KEYS) {
46
40
  const locked = sha512Of(entry.integrity);
47
41
  const signed = sha512Of(facts.integrity);
48
42
  if (locked === undefined)
49
- // No hash at all is install/integrity's finding.
50
43
  return entry.integrity === undefined
51
44
  ? []
52
45
  : [
@@ -3,7 +3,6 @@ import { gunzipSync } from "node:zlib";
3
3
  export const DEFAULT_TAR_LIMITS = {
4
4
  maxUnpackedBytes: 256 * 1024 * 1024,
5
5
  maxEntries: 20_000,
6
- // Bundles of a few MB are common in packages; reading them is cheap in memory.
7
6
  maxFileBytes: 8 * 1024 * 1024,
8
7
  };
9
8
  export class TarballError extends Error {
@@ -32,7 +31,6 @@ function verifyChecksum(block) {
32
31
  if (sum !== expected)
33
32
  throw new TarballError("header checksum mismatch: corrupted or crafted archive");
34
33
  }
35
- /** pax records: "<len> key=value\n" … */
36
34
  function paxRecords(data) {
37
35
  const records = new Map();
38
36
  let offset = 0;
@@ -55,9 +53,7 @@ function paxRecords(data) {
55
53
  }
56
54
  return records;
57
55
  }
58
- /** POSIX ustar ("ustar\0" + "00"): only then does node-tar read the name prefix. */
59
56
  const isUstar = (block) => block.toString("latin1", 257, 265) === "ustar\u000000";
60
- /** Drop the archive's top-level directory (`package/` in npm tarballs), as npm does. */
61
57
  function relative(path) {
62
58
  const slash = path.indexOf("/");
63
59
  const rest = slash === -1 ? "" : path.slice(slash + 1);
@@ -72,7 +68,6 @@ function relative(path) {
72
68
  }
73
69
  const FILE_TYPES = new Set(["0", "\0", "7"]);
74
70
  const LINK_TYPES = new Set(["1", "2"]);
75
- /** Devices, FIFOs and directories carry no code. */
76
71
  const IGNORED_TYPES = new Set(["3", "4", "5", "6"]);
77
72
  export function snapshotTarball(gzipped, limits = {}) {
78
73
  const { maxUnpackedBytes, maxEntries, maxFileBytes } = {
@@ -98,8 +93,6 @@ export function snapshotTarball(gzipped, limits = {}) {
98
93
  while (offset + BLOCK <= tar.length) {
99
94
  const block = tar.subarray(offset, offset + BLOCK);
100
95
  if (block.every((byte) => byte === 0)) {
101
- // node-tar ends the archive only at two zero blocks in a row; a single
102
- // one followed by more entries would hide them from croak alone.
103
96
  const next = tar.subarray(offset + BLOCK, offset + 2 * BLOCK);
104
97
  if (next.length === BLOCK && !next.every((byte) => byte === 0))
105
98
  throw new TarballError("a zero block in the middle of the archive: entries after it would be hidden");
@@ -132,7 +125,7 @@ export function snapshotTarball(gzipped, limits = {}) {
132
125
  continue;
133
126
  }
134
127
  if (type === "K")
135
- continue; // long link target: not a name
128
+ continue;
136
129
  if (type === "g") {
137
130
  const records = paxRecords(data);
138
131
  if (records.has("path") || records.has("size"))
@@ -17,7 +17,6 @@ export function workspaceRoot(dir, manager, fs) {
17
17
  manifest = JSON.parse(stripBom(fs.readFile(path)));
18
18
  }
19
19
  catch (error) {
20
- // npm fails on it too; it hides no install.
21
20
  if (!(error instanceof SyntaxError))
22
21
  throw error;
23
22
  }
@@ -1,12 +1,3 @@
1
- /**
2
- * A strict reader for the YAML that pnpm writes in pnpm-lock.yaml (M6, D38):
3
- * block mappings by indentation, plain and quoted scalars, flow mappings
4
- * `{a: b}` and flow sequences `[a, b]`, several documents split by `---`.
5
- * The lockfile comes from an untrusted repo, so anything outside that subset
6
- * (anchors, tags, block sequences, multi-line scalars) is a YamlError — the
7
- * check is then incomplete, never guessed at. Linear scanning; size, depth
8
- * and key counts are capped.
9
- */
10
1
  export class YamlError extends Error {
11
2
  constructor(message) {
12
3
  super(message);
@@ -18,7 +9,6 @@ export const DEFAULT_YAML_LIMITS = {
18
9
  maxDepth: 32,
19
10
  maxKeys: 2_000_000,
20
11
  };
21
- /** A scalar: 'single', "double" (simple escapes only) or plain. */
22
12
  function scalar(raw, where) {
23
13
  const text = raw.trim();
24
14
  if (text.startsWith("'")) {
@@ -41,7 +31,6 @@ function scalar(raw, where) {
41
31
  throw new YamlError(`${where}: unsupported YAML (${text.slice(0, 1)})`);
42
32
  return text;
43
33
  }
44
- /** Splits a flow collection's body at top-level commas, respecting quotes and nesting. */
45
34
  function splitFlow(body, where) {
46
35
  const parts = [];
47
36
  let depth = 0;
@@ -73,7 +62,6 @@ function splitFlow(body, where) {
73
62
  parts.push(last);
74
63
  return parts.map((p) => p.trim()).filter((p) => p !== "");
75
64
  }
76
- /** The index of the `: ` (or a final `:`) that ends a key, outside quotes and brackets. */
77
65
  function keyEnd(text) {
78
66
  let quote;
79
67
  let depth = 0;
@@ -121,7 +109,6 @@ function flowValue(raw, where, depth, limits) {
121
109
  }
122
110
  function parseDocument(lines, limits) {
123
111
  const root = {};
124
- // Open mappings by indentation: the deepest last.
125
112
  const stack = [
126
113
  { indent: -1, map: root },
127
114
  ];
@@ -134,8 +121,6 @@ function parseDocument(lines, limits) {
134
121
  throw new YamlError(`${where}: tab character`);
135
122
  const indent = text.length - text.trimStart().length;
136
123
  const body = text.trim();
137
- // A block sequence of scalars under a key (pnpm writes
138
- // transitivePeerDependencies this way); nothing nested.
139
124
  if (body.startsWith("- ") || body === "-") {
140
125
  if (pending !== undefined && indent >= pending.indent) {
141
126
  sequence = { indent, items: [] };
@@ -151,8 +136,6 @@ function parseDocument(lines, limits) {
151
136
  continue;
152
137
  }
153
138
  sequence = undefined;
154
- // A flow collection on the line after its key (as Biome and Prettier
155
- // write it) is that key's value; anywhere else it is not YAML pnpm writes.
156
139
  if (body.startsWith("{") || body.startsWith("[")) {
157
140
  if (pending === undefined || indent <= pending.indent)
158
141
  throw new YamlError(`${where}: a flow collection without a key`);
@@ -162,7 +145,6 @@ function parseDocument(lines, limits) {
162
145
  }
163
146
  let opened = false;
164
147
  if (pending !== undefined) {
165
- // The previous key opened a nested mapping (or is empty if this line is not deeper).
166
148
  if (indent > pending.indent) {
167
149
  const child = {};
168
150
  pending.parent[pending.key] = child;
@@ -203,10 +185,6 @@ function parseDocument(lines, limits) {
203
185
  pending.parent[pending.key] = {};
204
186
  return root;
205
187
  }
206
- /**
207
- * A line without its comment: a `#` outside quotes that starts the line or
208
- * follows whitespace (YAML's rule; `repo#commit` keeps its `#`).
209
- */
210
188
  function stripComment(line) {
211
189
  let quote;
212
190
  for (let i = 0; i < line.length; i++) {
@@ -223,7 +201,6 @@ function stripComment(line) {
223
201
  }
224
202
  return line;
225
203
  }
226
- /** How many flow brackets a line leaves open, outside quotes. */
227
204
  function openBrackets(line, start) {
228
205
  let depth = start;
229
206
  let quote;
@@ -241,14 +218,11 @@ function openBrackets(line, start) {
241
218
  }
242
219
  return depth;
243
220
  }
244
- /** Every document in the text (split at `---` lines), as plain objects. */
245
221
  export function parseYamlDocuments(text, limits = {}) {
246
222
  const all = { ...DEFAULT_YAML_LIMITS, ...limits };
247
223
  if (text.length > all.maxBytes)
248
224
  throw new YamlError(`larger than ${all.maxBytes} bytes`);
249
225
  const docs = [[]];
250
- // A flow collection a formatter spread over several lines (Biome, Prettier)
251
- // is joined back into one logical line, as YAML reads it.
252
226
  let open;
253
227
  const lines = text.split("\n");
254
228
  lines.forEach((raw, index) => {
@@ -1,16 +1,8 @@
1
1
  import { DEFAULT_MODEL, triage } from "./triage.js";
2
- /**
3
- * The model for `croak plan --llm`, loaded on first use (M5 review, D36). The
4
- * SDK is the one runtime dependency; importing it statically would load it,
5
- * and its own dependencies, for every croak command — the hook's steps run
6
- * on every agent command, outside the sandbox. This literal dynamic import is
7
- * the only one the invariant tripwire allows.
8
- */
9
2
  export function anthropicTriage(apiKey) {
10
3
  let ready;
11
4
  const load = async () => {
12
5
  const { default: Anthropic } = await import("@anthropic-ai/sdk");
13
- // Well inside the hook's timeout; one retry for an overloaded API.
14
6
  const client = new Anthropic({ apiKey, timeout: 30_000, maxRetries: 1 });
15
7
  return (input) => triage(input, client);
16
8
  };
package/dist/llm/cost.js CHANGED
@@ -7,7 +7,6 @@ export const PRICES = {
7
7
  longOutput: 2.5,
8
8
  },
9
9
  };
10
- /** Cost in USD, or undefined for a model without a known price. */
11
10
  export function costOf(model, usage) {
12
11
  const price = PRICES[model];
13
12
  if (price === undefined)
@@ -18,7 +17,6 @@ export function costOf(model, usage) {
18
17
  const long = price.longAbove !== undefined && prompt > price.longAbove;
19
18
  const input = long ? (price.longInput ?? price.input) : price.input;
20
19
  const output = long ? (price.longOutput ?? price.output) : price.output;
21
- // Cache writes cost 1.25x and reads 0.1x the input price.
22
20
  const promptCost = usage.input_tokens * input +
23
21
  (usage.cache_creation_input_tokens ?? 0) * input * 1.25 +
24
22
  (usage.cache_read_input_tokens ?? 0) * input * 0.1;
package/dist/llm/diff.js CHANGED
@@ -1,16 +1,7 @@
1
1
  import { posix } from "node:path";
2
2
  import { analyzeShell } from "../signals/shell.js";
3
3
  import { stripBom } from "../signals/text.js";
4
- /**
5
- * What the model sees (M5, D36): the added and changed lines between a
6
- * package's previous version and this one, most telling files first —
7
- * package.json, then the files its install scripts run, then other code —
8
- * within a character budget. Attacks add code, so removed lines are only
9
- * counted. A new package (no previous version) is shown as all added.
10
- */
11
- /** Room for the most telling files; triage cuts anything longer. */
12
4
  export const DIFF_BUDGET = 50_000;
13
- /** Minified bundles are one huge line; each shown line is cut to this. */
14
5
  const MAX_LINE = 400;
15
6
  const CODE = /\.(?:[cm]?[jt]sx?|json|sh|bash|py|gyp)$/;
16
7
  const SKIPPED = /\.(?:d\.[cm]?ts|map)$|(?:^|\/)(?:package-lock|npm-shrinkwrap)\.json$/;
@@ -18,7 +9,6 @@ const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"];
18
9
  function isRecord(value) {
19
10
  return typeof value === "object" && value !== null && !Array.isArray(value);
20
11
  }
21
- /** Files the install scripts run, from the new version's package.json. */
22
12
  function installFiles(after) {
23
13
  const files = new Set();
24
14
  let manifest;
@@ -40,7 +30,6 @@ function installFiles(after) {
40
30
  const cut = (line) => line.length > MAX_LINE
41
31
  ? `${line.slice(0, MAX_LINE)}… (${line.length} characters)`
42
32
  : line;
43
- /** Lines of `after` that `before` does not have, with their line numbers. */
44
33
  function addedLines(before, after) {
45
34
  const old = new Map();
46
35
  for (const line of (before ?? "").split("\n"))
@@ -76,8 +65,6 @@ export function versionDiff(before, after, budget = DIFF_BUDGET) {
76
65
  continue;
77
66
  }
78
67
  const text = after.read(path) ?? "";
79
- // A file the install scripts run is shown whole, changed or not: a payload
80
- // can sit unchanged until a new version starts running it (M5 review).
81
68
  const runsAtInstall = install.has(path);
82
69
  const old = runsAtInstall ? undefined : before?.read(path);
83
70
  if (!runsAtInstall && old === text)
@@ -92,7 +79,6 @@ export function versionDiff(before, after, budget = DIFF_BUDGET) {
92
79
  : `=== ${path} (changed: ${lines.length} line(s) added, ${removed} removed)`;
93
80
  const section = [head, ...lines].join("\n");
94
81
  if (used + section.length > budget) {
95
- // A file that does not fit still gets its header, so the model knows it exists.
96
82
  notes.push(`${head.slice(4)}: not shown, over the size budget`);
97
83
  continue;
98
84
  }
@@ -32,7 +32,6 @@ export async function llmStep(findings, changes, docs, llm, fetchOptions) {
32
32
  const left = maxBytes - downloaded;
33
33
  if (left <= 0)
34
34
  throw new RegistryError(`over the LLM step's ${maxBytes}-byte download budget`);
35
- // fetchTarball stops reading at the cap, so the budget holds mid-download too.
36
35
  const data = await fetchTarball(url, integrity, {
37
36
  ...fetchOptions,
38
37
  maxBytes: Math.min(left, 100 * 1024 * 1024),
@@ -40,8 +39,6 @@ export async function llmStep(findings, changes, docs, llm, fetchOptions) {
40
39
  downloaded += data.length;
41
40
  return data;
42
41
  };
43
- // Every copy with a warning: D35 keeps same name@version from different
44
- // sources apart, and the warned one may not be the first (M5 review).
45
42
  for (const change of changes.filter((c) => warned.has(`${c.entry.name}@${c.entry.version ?? "?"}`))) {
46
43
  const { entry } = change;
47
44
  const label = `${entry.name}@${entry.version ?? "?"}`;
@@ -57,8 +54,6 @@ export async function llmStep(findings, changes, docs, llm, fetchOptions) {
57
54
  asked += 1;
58
55
  try {
59
56
  const after = snapshotTarball(await download(entry.resolved ?? "", entry.integrity));
60
- // The baseline is what the project had: a new dependency is shown whole,
61
- // not as a delta from the registry's previous release (M5 review).
62
57
  const previous = change.kind === "changed" ? change.previousVersion : undefined;
63
58
  const prevTarball = previous === undefined ? undefined : tarballOf(doc, previous);
64
59
  const before = prevTarball === undefined
@@ -1,12 +1,6 @@
1
1
  import { verdictRank } from "../verdict.js";
2
- /** name@version at the start of a finding's file: "x@1.0.0/setup.js" → "x@1.0.0". */
3
2
  const LABEL = /^((?:@[^/@]+\/)?[^/@][^/@]*@[^/]+)/;
4
3
  export const labelOf = (file) => LABEL.exec(file)?.[1];
5
- /**
6
- * Packages the rules warned about and did not block: the only ones the model
7
- * is asked about (D36). A block needs no second opinion; an allow is not
8
- * escalated, to keep the cost to the packages a human would look at anyway.
9
- */
10
4
  export function warnedPackages(findings) {
11
5
  const worst = new Map();
12
6
  for (const f of findings) {
@@ -1,16 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { costOf } from "./cost.js";
3
- /**
4
- * One cheap, focused LLM call per package (M5, D36): Claude Haiku 5.5 reads
5
- * what changed between a package's previous version and this one, and says
6
- * whether the change looks malicious. Its answer can only escalate (see
7
- * escalate.ts). Package contents are untrusted input: they arrive inside a
8
- * per-call random boundary, the instructions say so, and the answer is held
9
- * to a JSON schema. Any failure is an LlmError, and croak falls back to its
10
- * deterministic verdict (invariant 3).
11
- */
12
3
  export const DEFAULT_MODEL = "claude-haiku-5-5";
13
- /** Diffs above this many characters are cut, and the model is told so. */
14
4
  export const MAX_DIFF_CHARS = 60_000;
15
5
  export class LlmError extends Error {
16
6
  constructor(message) {
@@ -1,10 +1,5 @@
1
1
  import { INCOMPLETE_ICON, SEVERITY_ICON, VERDICT_ICON } from "./ui.js";
2
- /** Bump when the JSON shape changes; agents and hooks depend on it. */
3
2
  export const REPORT_SCHEMA = 1;
4
- /**
5
- * What each verdict means, said out loud: neither green nor yellow is a
6
- * promise that the repo is safe to run.
7
- */
8
3
  const MEANING = {
9
4
  allow: "Nothing suspicious found. Not a guarantee: croak only knows what it checks.",
10
5
  warn: "Needs your attention: croak found behavior worth reviewing before you install or run anything. This is not a safe verdict.",
@@ -19,11 +14,6 @@ const MESSAGE_INDENT = " ".repeat(11);
19
14
  function sortFindings(findings) {
20
15
  return [...findings].sort((a, b) => ORDER[a.severity] - ORDER[b.severity] || a.file.localeCompare(b.file));
21
16
  }
22
- /**
23
- * Text from the scanned repo (commands, file names, hidden code) is hostile.
24
- * Escape C0/C1 control characters and bidi controls so it cannot move the
25
- * cursor, erase the 🔴 lines, or reorder the report itself (Trojan Source).
26
- */
27
17
  function isUnsafe(code) {
28
18
  return (code <= 0x1f ||
29
19
  (code >= 0x7f && code <= 0x9f) ||
@@ -71,7 +61,6 @@ export function renderScan(outcome, options) {
71
61
  footer: `${outcome.filesScanned} files scanned · ${Math.round(outcome.durationMs)} ms`,
72
62
  }, options);
73
63
  }
74
- /** The shared verdict layout for `croak repo` and `croak plan`. */
75
64
  export function renderVerdict(report, options) {
76
65
  const { emoji } = options;
77
66
  const status = emoji
@@ -5,33 +5,21 @@ export const DEFAULT_LIMITS = {
5
5
  maxEntries: 100_000,
6
6
  maxDepth: 64,
7
7
  };
8
- /** The repo exceeds a safety limit. Callers fail closed: an incomplete scan is not a clean scan. */
9
8
  export class SnapshotLimitError extends Error {
10
9
  constructor(message) {
11
10
  super(message);
12
11
  this.name = "SnapshotLimitError";
13
12
  }
14
13
  }
15
- /** A file changed between listing and reading. Callers fail closed. */
16
14
  export class SnapshotIntegrityError extends Error {
17
15
  constructor(message) {
18
16
  super(message);
19
17
  this.name = "SnapshotIntegrityError";
20
18
  }
21
19
  }
22
- /** What git itself executes from inside a root .git directory. */
23
20
  const GIT_EXECUTED_FILES = ["config"];
24
21
  const GIT_HOOKS_DIR = "hooks";
25
- /**
26
- * Lists a repository without following symlinks. Symlinks and oversized files
27
- * are recorded (detectors may flag them) but never read. node_modules and
28
- * nested .git directories are walked on purpose: both are hiding places. The
29
- * root .git contributes only what git executes (config, hooks), not its
30
- * object store.
31
- */
32
22
  export function snapshotRepo(root, limits = {}) {
33
- // O_NOFOLLOW is the read-time symlink guard; without it (Windows) we refuse
34
- // rather than silently lose the protection.
35
23
  if (typeof constants.O_NOFOLLOW !== "number") {
36
24
  throw new Error("croak requires a POSIX platform (O_NOFOLLOW unavailable)");
37
25
  }
@@ -58,7 +46,6 @@ export function snapshotRepo(root, limits = {}) {
58
46
  identities.set(path, { dev: stats.dev, ino: stats.ino });
59
47
  }
60
48
  };
61
- /** Streams one directory's entries instead of loading them all at once. */
62
49
  const eachEntry = (dir, visit) => {
63
50
  const handle = opendirSync(dir);
64
51
  try {
@@ -127,8 +114,6 @@ export function snapshotRepo(root, limits = {}) {
127
114
  const expected = identities.get(path);
128
115
  if (expected === undefined)
129
116
  return undefined;
130
- // O_NOFOLLOW refuses a final-component symlink; the inode check below
131
- // catches any other swap, including an intermediate directory.
132
117
  const fd = openSync(join(root, path), constants.O_RDONLY | constants.O_NOFOLLOW);
133
118
  try {
134
119
  const stats = fstatSync(fd, { bigint: true });
@@ -137,8 +122,6 @@ export function snapshotRepo(root, limits = {}) {
137
122
  stats.ino !== expected.ino) {
138
123
  throw new SnapshotIntegrityError(`${path} changed after it was listed`);
139
124
  }
140
- // Read at most one byte past the cap, so growth after listing is detected
141
- // instead of silently read.
142
125
  const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
143
126
  let length = 0;
144
127
  for (let n = readSync(fd, buffer, 0, buffer.length, 0); n > 0 && length < buffer.length; n = readSync(fd, buffer, length, buffer.length - length, length)) {
package/dist/scan.js CHANGED
@@ -3,17 +3,12 @@ import { performance } from "node:perf_hooks";
3
3
  import { repoDetectors, scanRepo, } from "./detectors/index.js";
4
4
  import { SnapshotIntegrityError, SnapshotLimitError, snapshotRepo, } from "./repo-snapshot.js";
5
5
  import { aggregate } from "./verdict.js";
6
- /** The scan target is missing or not a directory: a usage error, not a verdict. */
7
6
  export class ScanTargetError extends Error {
8
7
  constructor(message) {
9
8
  super(message);
10
9
  this.name = "ScanTargetError";
11
10
  }
12
11
  }
13
- /**
14
- * Scans a repository directory. The root itself is chosen by the user and may
15
- * itself be a symbolic link, like /tmp on macOS; nothing inside it is followed.
16
- */
17
12
  export function scanRepoAt(root, options = {}) {
18
13
  if (statSync(root, { throwIfNoEntry: false })?.isDirectory() !== true) {
19
14
  throw new ScanTargetError(`${root} is not a directory`);
@@ -1,11 +1,3 @@
1
- // croak-invariants: pattern-data
2
- // Detector patterns and test inputs below are data, not code (D16).
3
- /**
4
- * Signals in JavaScript/TypeScript source that, in combination, indicate an
5
- * attack. A single signal is context, not evidence: install scripts fetch
6
- * binaries, build tools spawn processes. Patterns are deliberately narrow and
7
- * linear (checked by `pnpm redos`): they run on attacker-controlled text.
8
- */
9
1
  const LOAD = String.raw `(?:require\s*\(\s*|from\s+|import\s*\(\s*)["']`;
10
2
  const load = (modules) => new RegExp(`${LOAD}(?:node:)?(?:${modules})["']`);
11
3
  export const CODE_PATTERNS = [
@@ -31,9 +23,7 @@ export const CODE_PATTERNS = [
31
23
  label: "process library",
32
24
  pattern: load("execa|shelljs|cross-spawn"),
33
25
  },
34
- // Not `$eval(` (Puppeteer and Playwright's page.$eval API).
35
26
  { signal: "eval", label: "eval", pattern: /(?<![\w$])eval\s*\)?\s*\(/ },
36
- // Not `Function("return this")`, the old idiom for the global object.
37
27
  {
38
28
  signal: "eval",
39
29
  label: "Function constructor",
@@ -61,14 +51,9 @@ export const CODE_PATTERNS = [
61
51
  label: "private key",
62
52
  pattern: /\bid_(?:rsa|ed25519|ecdsa|dsa)\b/,
63
53
  },
64
- // The whole environment, not process.env.PORT, a destructured
65
- // `{ PORT } = process.env`, or `{ env: process.env }` handed to a child.
66
54
  {
67
55
  signal: "secrets",
68
56
  label: "whole process.env",
69
- // passed whole to a call as its first or last argument (JSON.stringify,
70
- // Object.entries, post(url, process.env)), spread into JSON.stringify, or
71
- // walked with for…in. A spread for a child's env stays quiet.
72
57
  pattern: /[(,]\s*process\s*\.\s*env\s*\)|\(\s*process\s*\.\s*env\s*,|\bstringify\s*\(\s*\{\s*\.\.\.\s*process\s*\.\s*env\b|\bin\s+process\s*\.\s*env\b/,
73
58
  },
74
59
  { signal: "secrets", label: "home directory", pattern: /\bhomedir\s*\(/ },
@@ -82,14 +67,12 @@ export const CODE_PATTERNS = [
82
67
  label: "browser credentials",
83
68
  pattern: /\b(?:Login Data|Local State)\b|\bKeychains?\b/,
84
69
  },
85
- // MetaMask's extension id: wallet stealers look for its storage directory.
86
70
  {
87
71
  signal: "secrets",
88
72
  label: "wallet",
89
73
  pattern: /\bnkbihfbeogaeaoehlefnkodbefgpgknn\b/,
90
74
  },
91
75
  ];
92
- /** Every pattern match, line by line. */
93
76
  export function codeSignals(text) {
94
77
  const hits = [];
95
78
  text.split("\n").forEach((lineText, index) => {
@@ -100,10 +83,6 @@ export function codeSignals(text) {
100
83
  });
101
84
  return hits;
102
85
  }
103
- /**
104
- * Network plus anything that reaches into the machine or hides code, or
105
- * eval of decoded data. Network alone is a warning, not an attack.
106
- */
107
86
  export function isSuspicious(signals) {
108
87
  const reach = signals.has("secrets") ||
109
88
  signals.has("process") ||
@@ -112,19 +91,11 @@ export function isSuspicious(signals) {
112
91
  return ((signals.has("network") && reach) ||
113
92
  (signals.has("eval") && signals.has("decode")));
114
93
  }
115
- /**
116
- * For dependency contents: native-binary installers (esbuild, swc, sharp)
117
- * legitimately download a binary, unpack it (String.fromCharCode on a tar
118
- * header), and spawn it at install time, so network plus process or decoding
119
- * alone only warns there. Network plus secrets or eval still blocks, as does
120
- * eval of decoded data.
121
- */
122
94
  export function isSuspiciousInDependency(signals) {
123
95
  const reach = signals.has("secrets") || signals.has("eval");
124
96
  return ((signals.has("network") && reach) ||
125
97
  (signals.has("eval") && signals.has("decode")));
126
98
  }
127
- /** "network (x.js:1), secrets (x.js:3)" — each signal once, at its first hit. */
128
99
  export function describeSignals(hits, file) {
129
100
  const first = new Map();
130
101
  for (const hit of hits)
@@ -1,12 +1,5 @@
1
- /**
2
- * A minimal git-config reader for .git/config and .gitmodules: sections,
3
- * `[name "subsection"]` and legacy `[name.sub]` headers, quoted values,
4
- * comments. Enough to see which settings make git run a command. Linear,
5
- * line by line; continuation lines are not supported.
6
- */
7
1
  const HEADER = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]\s*(.*)$/;
8
2
  const KEY = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/;
9
- /** Removes a trailing comment outside quotes, then quotes and escapes. */
10
3
  function parseValue(raw) {
11
4
  let out = "";
12
5
  let quoted = false;
@@ -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",