@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
package/dist/cli.js CHANGED
@@ -18,14 +18,8 @@ import { anthropicTriage, DEFAULT_MODEL } from "./llm/client.js";
18
18
  import { renderScan, scanToJson } from "./repo-report.js";
19
19
  import { ScanTargetError, scanRepoAt } from "./scan.js";
20
20
  import { aggregate } from "./verdict.js";
21
- export const VERSION = "0.1.0";
22
- /**
23
- * 0 allow or warn (warnings are printed, the process succeeds),
24
- * 1 block, 2 usage error, 3 the scan could not complete — agents treat 3 as
25
- * a block (fail-closed).
26
- */
21
+ export const VERSION = "0.1.3";
27
22
  export const EXIT = { ok: 0, block: 1, usage: 2, incomplete: 3 };
28
- /** Conventional exit code for invalid command-line usage. */
29
23
  export const EXIT_USAGE = EXIT.usage;
30
24
  const DEFAULT_DEPS = {
31
25
  scan: (root) => scanRepoAt(root),
@@ -110,7 +104,6 @@ async function runPlan(values, rest, deps) {
110
104
  if (values.installed !== undefined && values.before !== undefined)
111
105
  return usageError("plan takes --before or --installed, not both");
112
106
  if (values.installed !== undefined) {
113
- // What is really in node_modules, verified folder by folder (D34).
114
107
  const tryRead = (path) => {
115
108
  try {
116
109
  return read(path);
@@ -119,7 +112,6 @@ async function runPlan(values, rest, deps) {
119
112
  return undefined;
120
113
  }
121
114
  };
122
- // The installed tree of the manager that wrote the plan (D38).
123
115
  before = isPnpmLockfile(after)
124
116
  ? installedPnpmLockfile(values.installed, tryRead, (dir) => {
125
117
  try {
@@ -137,7 +129,6 @@ async function runPlan(values, rest, deps) {
137
129
  return text;
138
130
  before = text;
139
131
  }
140
- // pnpm's own rule for whose install scripts run, from the project's settings (D38).
141
132
  const buildsDir = values["pnpm-builds"];
142
133
  const builds = buildsDir === undefined || values["pnpm-version"] === undefined
143
134
  ? undefined
@@ -159,7 +150,6 @@ async function runPlan(values, rest, deps) {
159
150
  }
160
151
  })(),
161
152
  }, values["pnpm-version"]);
162
- // --offline: lockfile signals only, no registry requests (tests, air-gapped CI).
163
153
  const outcome = values.offline === true
164
154
  ? planInstall(before, after)
165
155
  : await planInstallOnline(before, after, {
@@ -173,7 +163,6 @@ async function runPlan(values, rest, deps) {
173
163
  ? { llm: deps.llm }
174
164
  : {}),
175
165
  });
176
- // --llm without a key: the rules decide, and the report says the model did not run.
177
166
  const llmMissing = values.llm === true && deps.llm === undefined && values.offline !== true
178
167
  ? [
179
168
  {
@@ -209,24 +198,17 @@ async function runPlan(values, rest, deps) {
209
198
  }
210
199
  },
211
200
  };
212
- // A plain npm install or npm ci also runs the project's own install scripts
213
- // (D35); pnpm runs the project's pnpmfile and config dependencies on every
214
- // install (D39).
215
201
  const project = values.project;
216
202
  const extra = [
217
203
  ...llmMissing,
218
- // The pnpmfile check needs only the project; the version is for the build rule.
219
204
  ...(buildsDir === undefined ? [] : pnpmfileFindings(buildsDir, projectFs)),
220
205
  ...(project === undefined || outcome.status !== "complete"
221
206
  ? []
222
207
  : projectScriptFindings(project, projectFs, {
223
- // The plan's lockfile parsed, so its members can be read (M6 review).
224
208
  members: workspaceMembers(after),
225
209
  pnpm: buildsDir !== undefined,
226
210
  })),
227
211
  ];
228
- // The folder the install ran in must be a member of the workspace the plan
229
- // was made at; otherwise the package manager installs elsewhere (M6 review).
230
212
  const member = values.member;
231
213
  const notMember = member !== undefined &&
232
214
  outcome.status === "complete" &&
@@ -248,9 +230,7 @@ async function runPlan(values, rest, deps) {
248
230
  : renderPlan(judged, { emoji: deps.emoji && values.plain !== true });
249
231
  return { code: exitCode(judged), stdout, stderr: "" };
250
232
  }
251
- /** Pure entry point: all I/O goes through deps. */
252
233
  export async function run(argv, deps = DEFAULT_DEPS) {
253
- // What the planning wrapper may hand to pnpm from an untrusted project (D38).
254
234
  if (argv[0] === "sanitize") {
255
235
  const [, kind, file] = argv;
256
236
  if (file === undefined ||
@@ -273,7 +253,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
273
253
  throw error;
274
254
  }
275
255
  }
276
- // The workspace root croak-install plans at (D38): the same reading as the hook's.
277
256
  if (argv[0] === "workspace-root") {
278
257
  const [, manager, dir] = argv;
279
258
  if (dir === undefined || (manager !== "npm" && manager !== "pnpm"))
@@ -288,7 +267,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
288
267
  stderr: "",
289
268
  };
290
269
  }
291
- // Suggestions for package names npm does not have (croak-install on E404).
292
270
  if (argv[0] === "suggest") {
293
271
  const lines = argv.slice(1).flatMap((spec) => {
294
272
  const names = suggestNames(decodeURIComponent(spec));
@@ -300,7 +278,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
300
278
  });
301
279
  return { code: EXIT.ok, stdout: lines.join(""), stderr: "" };
302
280
  }
303
- // The agent hook's two steps (scripts/croak-hook.sh); they read files only.
304
281
  if (argv[0] === "hook")
305
282
  return runHook(argv.slice(1), {
306
283
  readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")),
@@ -313,7 +290,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
313
290
  parsed = parse(argv);
314
291
  }
315
292
  catch (error) {
316
- // Usage mistakes are explained, not crashed on; anything else is a bug.
317
293
  if (!isParseArgsError(error))
318
294
  throw error;
319
295
  return usageError(error.message);
@@ -345,9 +321,7 @@ export async function run(argv, deps = DEFAULT_DEPS) {
345
321
  : renderScan(outcome, { emoji: deps.emoji && values.plain !== true });
346
322
  return { code: exitCode(outcome), stdout, stderr: "" };
347
323
  }
348
- // Only touch the process when executed directly, not when imported by tests.
349
324
  if (import.meta.main) {
350
- // Built on first use: fingerprinting croak's code costs a directory read.
351
325
  let cache;
352
326
  const contentCache = () => {
353
327
  cache ??= fileContentCache(join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "croak", "content"), codeFingerprint(import.meta.dirname));
@@ -1,9 +1,3 @@
1
- /**
2
- * Just enough DER to read the build identity Fulcio writes into a signing
3
- * certificate (M7, D42). node:crypto verifies certificates but does not expose
4
- * custom extensions. Certificates come from the registry, so every length is
5
- * checked against the bytes that are there.
6
- */
7
1
  export class DerError extends Error {
8
2
  constructor(message) {
9
3
  super(message);
@@ -41,7 +35,6 @@ export function readTlv(buf, offset) {
41
35
  throw new DerError(`element at byte ${offset} runs past the end`);
42
36
  return { tag, start, end };
43
37
  }
44
- /** The elements inside a constructed element, in order. */
45
38
  export function children(buf, parent) {
46
39
  const out = [];
47
40
  for (let at = parent.start; at < parent.end;) {
@@ -63,7 +56,6 @@ function oidOf(buf, tlv) {
63
56
  const first = bytes[0];
64
57
  if (first === undefined)
65
58
  throw new DerError("empty OID");
66
- // The first byte packs the first two arcs as 40 * a + b (a is at most 2).
67
59
  const top = Math.min(2, Math.floor(first / 40));
68
60
  const arcs = [top, first - top * 40];
69
61
  let value = 0;
@@ -78,7 +70,6 @@ function oidOf(buf, tlv) {
78
70
  }
79
71
  return arcs.join(".");
80
72
  }
81
- // Fulcio's v2 extensions (1.3.6.1.4.1.57264.1.8 onwards), each a DER UTF8String.
82
73
  const FULCIO = "1.3.6.1.4.1.57264.1.";
83
74
  const FIELDS = {
84
75
  [`${FULCIO}8`]: "issuer",
@@ -95,10 +86,6 @@ const OCTET_STRING = 0x04;
95
86
  const SEQUENCE = 0x30;
96
87
  const OID = 0x06;
97
88
  const EXTENSIONS = 0xa3;
98
- /**
99
- * The build identity in a Fulcio certificate. Reads only; whether the
100
- * certificate is genuine is src/crypto/sigstore.ts's job.
101
- */
102
89
  export function fulcioIdentity(certDer) {
103
90
  const cert = expectTag(readTlv(certDer, 0), SEQUENCE, "a certificate");
104
91
  const tbs = expectTag(children(certDer, cert)[0], SEQUENCE, "tbsCertificate");
@@ -1,9 +1,4 @@
1
1
  import { verify, X509Certificate } from "node:crypto";
2
- /**
3
- * Reading Sigstore bundles, which come from the registry: every field is
4
- * checked for shape before it is used (M7, D43).
5
- */
6
- /** Why a bundle check stopped: it is wrong, or croak cannot tell. */
7
2
  export class SigstoreError extends Error {
8
3
  status;
9
4
  constructor(status, message) {
@@ -17,7 +12,6 @@ export const unverifiable = (message) => new SigstoreError("unverifiable", messa
17
12
  export function isRecord(value) {
18
13
  return typeof value === "object" && value !== null && !Array.isArray(value);
19
14
  }
20
- /** The string at `path` inside `value`; anything else is an invalid bundle. */
21
15
  export function str(value, ...path) {
22
16
  let at = value;
23
17
  for (const key of path) {
@@ -45,11 +39,6 @@ export function certFrom(der, what) {
45
39
  throw invalid(`${what} is not a certificate`);
46
40
  }
47
41
  }
48
- /**
49
- * A SHA-256 signature check that answers false instead of throwing: keys
50
- * and signatures come from the registry, and node:crypto throws on some
51
- * (an Ed25519 key with a digest, malformed DER).
52
- */
53
42
  export function verifies(data, key, signature) {
54
43
  try {
55
44
  return verify("sha256", data, key, signature);
@@ -4,12 +4,6 @@ const p256 = (spkiBase64) => createPublicKey({
4
4
  format: "der",
5
5
  type: "spki",
6
6
  });
7
- /**
8
- * registry.npmjs.org/-/npm/v1/keys, pinned 2026-10-31 (D42). Pinned, not
9
- * fetched: keys from the same server as the packument would only check the
10
- * server against itself. A key croak does not know is "unverifiable", never
11
- * a pass.
12
- */
13
7
  export const NPM_REGISTRY_KEYS = [
14
8
  {
15
9
  keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
@@ -38,15 +32,9 @@ function verifies(message, key, sig) {
38
32
  return verify("sha256", message, key, Buffer.from(sig, "base64"));
39
33
  }
40
34
  catch {
41
- // Malformed DER is a bad signature, not a crash.
42
35
  return false;
43
36
  }
44
37
  }
45
- /**
46
- * Like npm's own check: every signature by a known key must verify, and a
47
- * key past its expiry signs nothing published after it. Signatures by keys
48
- * croak does not know are skipped while a known one verifies.
49
- */
50
38
  export function checkRegistrySignature(pkg, keys = NPM_REGISTRY_KEYS) {
51
39
  const signatures = signaturesOf(pkg.signatures);
52
40
  if (signatures.length === 0)
@@ -7,20 +7,9 @@ export function rekorLog(spkiBase64) {
7
7
  publicKey: createPublicKey({ key: der, format: "der", type: "spki" }),
8
8
  };
9
9
  }
10
- /**
11
- * rekor.sigstore.dev, pinned 2026-10-31 from github.com/sigstore/root-signing
12
- * targets/trusted_root.json (D42). Rekor v2 logs (Ed25519, no SET) are not
13
- * pinned: their entries are unverifiable (D43).
14
- */
15
10
  export const REKOR_V1 = rekorLog("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==");
16
11
  const SUPPORTED = ["dsse 0.0.1", "intoto 0.0.2"];
17
- /** A PEM certificate as Rekor stores it (base64 of the PEM text), as DER. */
18
12
  const pemCertDer = (base64Pem) => certFrom(b64(base64Pem), "the logged certificate").raw;
19
- /**
20
- * Verifies the SET over a bundle's log entry and reads what it recorded.
21
- * Throws a SigstoreError: invalid when the entry is not what the log
22
- * signed, unverifiable for a log, kind or shape croak does not support.
23
- */
24
13
  export function readLogEntry(entry, log) {
25
14
  const kind = `${str(entry, "kindVersion", "kind")} ${str(entry, "kindVersion", "version")}`;
26
15
  if (!SUPPORTED.includes(kind))
@@ -35,7 +24,6 @@ export function readLogEntry(entry, log) {
35
24
  if (!/^\d{1,15}$/.test(integratedTime) || !/^\d{1,15}$/.test(logIndex))
36
25
  throw invalid("the log entry's time or index is not a number");
37
26
  const body = str(entry, "canonicalizedBody");
38
- // Rekor signs the canonical JSON of these four fields, keys sorted.
39
27
  const promise = JSON.stringify({
40
28
  body,
41
29
  integratedTime: Number(integratedTime),
@@ -59,7 +47,6 @@ export function readLogEntry(entry, log) {
59
47
  certificateDer: pemCertDer(str(logged, "spec", "signatures", 0, "verifier")),
60
48
  payloadHash: str(logged, "spec", "payloadHash", "value"),
61
49
  };
62
- // intoto 0.0.2 stores the signature base64-encoded once more.
63
50
  const signer = ["spec", "content", "envelope", "signatures", 0];
64
51
  return {
65
52
  loggedAt,
@@ -3,11 +3,6 @@ import { DerError, fulcioIdentity } from "./der.js";
3
3
  import { b64, certFrom, invalid, isRecord, SigstoreError, str, unverifiable, verifies, } from "./fields.js";
4
4
  import { REKOR_V1, readLogEntry } from "./rekor.js";
5
5
  const pinned = (base64) => certFrom(Buffer.from(base64, "base64"), "a pinned Fulcio certificate");
6
- /**
7
- * Sigstore's public-good instance, pinned 2026-10-31 from
8
- * github.com/sigstore/root-signing targets/trusted_root.json (D42): the
9
- * Fulcio CA valid since 2022-04-13 (npm provenance began in 2023).
10
- */
11
6
  export const SIGSTORE_PUBLIC_GOOD = {
12
7
  fulcio: [
13
8
  pinned("MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="),
@@ -38,12 +33,10 @@ function checkCertificate(leaf, chain, at) {
38
33
  if (at < leaf.validFromDate || at > leaf.validToDate)
39
34
  throw invalid("it was logged outside the signing certificate's lifetime");
40
35
  }
41
- /** DSSE's pre-authentication encoding: what the envelope signature covers. */
42
36
  const pae = (type, payload) => Buffer.concat([
43
37
  Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${payload.length} `),
44
38
  payload,
45
39
  ]);
46
- /** A package URL with its escapes undone (npm writes `pkg:npm/%40scope/name@1.0.0`). */
47
40
  function purlName(purl) {
48
41
  try {
49
42
  return decodeURIComponent(purl);
@@ -89,7 +82,6 @@ function check(bundle, subject, trust) {
89
82
  throw invalid(`the envelope carries ${type}, not an in-toto statement`);
90
83
  const payload = b64(str(bundle, "dsseEnvelope", "payload"));
91
84
  const sig = str(bundle, "dsseEnvelope", "signatures", 0, "sig");
92
- // Fulcio also certifies Ed25519 keys; npm's are EC. Others are unchecked.
93
85
  const keyType = leaf.publicKey.asymmetricKeyType;
94
86
  if (keyType !== "ec" && keyType !== "rsa")
95
87
  throw unverifiable(`the signing key is ${keyType ?? "of an unknown type"}`);
@@ -106,7 +98,6 @@ function check(bundle, subject, trust) {
106
98
  loggedAt: logged.loggedAt,
107
99
  };
108
100
  }
109
- /** Verifies an npm provenance bundle (the SLSA one) for this exact tarball. */
110
101
  export function verifyProvenance(bundle, subject, trust = SIGSTORE_PUBLIC_GOOD) {
111
102
  try {
112
103
  return check(bundle, subject, trust);
@@ -19,7 +19,6 @@ const BOOLEAN = new Set([
19
19
  ]);
20
20
  const MAX_SHOWN = 60;
21
21
  const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
22
- /** What git does with a setting, or undefined when the setting runs nothing. */
23
22
  function classify({ section, key, value }) {
24
23
  const name = `${section.split(".")[0] ?? ""}.${key}`;
25
24
  if (section === "core" && key === "fsmonitor") {
@@ -41,13 +40,11 @@ function classify({ section, key, value }) {
41
40
  return { when: "runs on every git diff", severity: "block" };
42
41
  if (section.startsWith("filter.") &&
43
42
  ["clean", "smudge", "process"].includes(key)) {
44
- // Git runs filter values through the shell: only the exact LFS command is benign.
45
43
  return LFS_FILTER.test(value)
46
44
  ? { when: "is a Git LFS filter", severity: "info" }
47
45
  : { when: "runs on checkout and add", severity: "block" };
48
46
  }
49
47
  if (name === "credential.helper") {
50
- // Only a bare helper name (osxkeychain, store, manager) is benign.
51
48
  return NAMED_HELPER.test(value)
52
49
  ? { when: "is a named credential helper", severity: "info" }
53
50
  : { when: "runs when git asks for credentials", severity: "block" };
@@ -125,7 +122,6 @@ const isGitConfig = (path) => path === ".git/config" ||
125
122
  path.endsWith("/.git/config") ||
126
123
  posix.basename(path) === ".gitmodules";
127
124
  const isActiveHook = (path) => /(?:^|\/)\.git\/hooks\/[^/]+$/.test(path) && !path.endsWith(".sample");
128
- /** Git settings and hooks that make ordinary git commands run code (e.g. a zip-shipped .git). */
129
125
  export const gitConfig = (repo) => repo.entries
130
126
  .filter((e) => e.kind === "file" && !isInNodeModules(e.path))
131
127
  .flatMap((e) => {
@@ -2,7 +2,6 @@ import { codeSignals } from "../signals/code.js";
2
2
  import { hiddenCode } from "../signals/hidden.js";
3
3
  import { isInNodeModules } from "./node-modules-present.js";
4
4
  const RULE = "repo/hidden-code";
5
- /** Files a toolchain, IDE, or shell may execute or load. */
6
5
  const SCANNED = /\.(?:[cm]?[jt]sx?|json|sh|bash|zsh|py|rb|pl|ps1)$/;
7
6
  const MAX_HITS_PER_FILE = 5;
8
7
  const SEVERITY_RANK = {
@@ -19,7 +18,6 @@ const DESCRIBE = {
19
18
  const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
20
19
  function severityOf(hit, path) {
21
20
  if (hit.kind === "bidi") {
22
- // RTL locale files legitimately contain bidi isolates.
23
21
  return path.endsWith(".json") ? "info" : "warn";
24
22
  }
25
23
  return codeSignals(hit.hidden).length > 0 ? "block" : "warn";
@@ -27,9 +25,6 @@ function severityOf(hit, path) {
27
25
  function finding(hit, path, downgradeInstalled) {
28
26
  const signals = [...new Set(codeSignals(hit.hidden).map((s) => s.signal))];
29
27
  const raw = severityOf(hit, path);
30
- // In a repo, installed and committed dependencies look the same and
31
- // repo/node-modules-present already flags the directory, so content inside
32
- // it is at most a warning. In a package, node_modules is bundled code npm runs.
33
28
  const severity = downgradeInstalled && isInNodeModules(path) && raw === "block"
34
29
  ? "warn"
35
30
  : raw;
@@ -43,13 +38,11 @@ function finding(hit, path, downgradeInstalled) {
43
38
  message: `${DESCRIBE[hit.kind]} at line ${hit.line}, column ${hit.column}${detail}${using}`,
44
39
  };
45
40
  }
46
- /** Code placed where a human reviewing the file will not see it. */
47
41
  export function hiddenCodeWith({ downgradeInstalled, }) {
48
42
  return (repo) => repo.entries
49
43
  .filter((e) => e.kind === "file" && SCANNED.test(e.path))
50
44
  .flatMap((e) => hiddenCode(repo.read(e.path) ?? "")
51
45
  .map((hit) => finding(hit, e.path, downgradeInstalled))
52
- // Most severe first, so harmless decoys cannot push the payload past the cap.
53
46
  .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
54
47
  .slice(0, MAX_HITS_PER_FILE));
55
48
  }
@@ -11,14 +11,11 @@ function isRecord(value) {
11
11
  }
12
12
  const asString = (value) => typeof value === "string" ? value : undefined;
13
13
  const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
14
- /** Lines (1-based) that mention folderOpen, in order: one per autorun task. */
15
14
  function folderOpenLines(text) {
16
15
  return text
17
16
  .split("\n")
18
17
  .flatMap((line, index) => (line.includes("folderOpen") ? [index + 1] : []));
19
18
  }
20
- /** The npm script a task runs, read from the package.json next to the .vscode folder. */
21
- /** The package.json next to the .vscode folder: its directory and scripts. */
22
19
  function manifestFor(repo, tasksPath) {
23
20
  const folder = posix.dirname(posix.dirname(tasksPath));
24
21
  const dir = folder === "." ? "" : folder;
@@ -35,7 +32,6 @@ function manifestFor(repo, tasksPath) {
35
32
  };
36
33
  }
37
34
  catch {
38
- // An unreadable package.json is reported by repo/lifecycle-script.
39
35
  return { dir, scripts: {} };
40
36
  }
41
37
  }
@@ -45,8 +41,6 @@ function taskFinding(repo, path, task, line, trigger) {
45
41
  const hidden = presentation.reveal === "never" ? ", with its terminal hidden" : "";
46
42
  const at = line === undefined ? { file: path } : { file: path, line };
47
43
  if (task.type === "npm") {
48
- // Same analysis as install scripts: the script, the files it runs, and
49
- // the scripts it calls through npm run.
50
44
  const script = asString(task.script) ?? "";
51
45
  const { dir, scripts } = manifestFor(repo, path);
52
46
  const command = asString(scripts[script]);
@@ -64,7 +58,6 @@ function taskFinding(repo, path, task, line, trigger) {
64
58
  message: `task "${label}" runs npm script "${script}" (${shown}) ${trigger}${hidden}${reasons}`,
65
59
  };
66
60
  }
67
- // shell, process, or no type: VS Code runs the command directly.
68
61
  const args = Array.isArray(task.args)
69
62
  ? task.args.filter((a) => typeof a === "string")
70
63
  : [];
@@ -111,7 +104,6 @@ function tasksFindings(repo, path) {
111
104
  const autorun = tasks.filter((task) => isRecord(task.runOptions) && task.runOptions.runOn === "folderOpen");
112
105
  const lines = folderOpenLines(text);
113
106
  const findings = autorun.map((task, index) => taskFinding(repo, path, task, lines[index], "when the folder is opened"));
114
- // Tasks an autorun task depends on run first, just as automatically.
115
107
  const seen = new Set(autorun);
116
108
  const queue = autorun.flatMap((task) => dependsOnLabels(task).map((dep) => ({
117
109
  dep,
@@ -128,7 +120,6 @@ function tasksFindings(repo, path) {
128
120
  }
129
121
  return findings;
130
122
  }
131
- /** VS Code tasks that run by themselves when the folder is opened. */
132
123
  export const ideAutorun = (repo) => repo.entries
133
124
  .filter((e) => e.kind === "file" &&
134
125
  (e.path === TASKS_FILE || e.path.endsWith(`/${TASKS_FILE}`)) &&
@@ -5,7 +5,6 @@ import { lifecycleScript } from "./lifecycle-script.js";
5
5
  import { nodeModulesPresent } from "./node-modules-present.js";
6
6
  import { obfuscation } from "./obfuscation.js";
7
7
  import { unscanned } from "./unscanned.js";
8
- /** Registered repo detectors. `unscanned` is the safety floor and always runs. */
9
8
  export const repoDetectors = [
10
9
  unscanned,
11
10
  nodeModulesPresent,
@@ -4,7 +4,6 @@ import { analyzeShell } from "../signals/shell.js";
4
4
  import { stripBom } from "../signals/text.js";
5
5
  import { isInNodeModules } from "./node-modules-present.js";
6
6
  const RULE = "repo/lifecycle-script";
7
- /** Every script npm runs during `npm install` in the project itself. */
8
7
  export const INSTALL_SCRIPTS = [
9
8
  "preinstall",
10
9
  "install",
@@ -13,21 +12,14 @@ export const INSTALL_SCRIPTS = [
13
12
  "preprepare",
14
13
  "prepare",
15
14
  "postprepare",
16
- // runs after node_modules changes (npm docs)
17
15
  "dependencies",
18
16
  ];
19
- /** The only scripts npm runs for a package installed from the registry. */
20
17
  export const DEPENDENCY_INSTALL_SCRIPTS = [
21
18
  "preinstall",
22
19
  "install",
23
20
  "postinstall",
24
21
  ];
25
- /** node_modules/<name>/package.json or node_modules/@scope/<name>/package.json, at any depth. */
26
22
  const BUNDLED_MANIFEST = /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/@][^/]*\/package\.json$/;
27
- /**
28
- * Tools that run no project code: their install-time use can be an info note
29
- * (D19). Everything they do is decided by the tool, not by files in the repo.
30
- */
31
23
  const INERT_TOOLS = new Set([
32
24
  "husky",
33
25
  "simple-git-hooks",
@@ -43,11 +35,6 @@ const INERT_TOOLS = new Set([
43
35
  "exit",
44
36
  "printf",
45
37
  ]);
46
- /**
47
- * Recognized install-time tools that execute code from the repo — a JS/TS
48
- * config, workspace tasks, a generator, or patches. Recognizing one adds a
49
- * note; the script stays a warning.
50
- */
51
38
  const EXECUTING_TOOLS = new Set([
52
39
  "prisma",
53
40
  "vite",
@@ -76,7 +63,6 @@ function isRecord(value) {
76
63
  return typeof value === "object" && value !== null && !Array.isArray(value);
77
64
  }
78
65
  const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
79
- /** 1-based line of `"name":` inside the manifest's "scripts" object, if found. */
80
66
  function scriptLine(manifest, name) {
81
67
  const lines = manifest.split("\n");
82
68
  const scripts = lines.findIndex((line) => /"scripts"\s*:/.test(line));
@@ -86,21 +72,13 @@ function scriptLine(manifest, name) {
86
72
  /"\s*:/.test(line.slice(line.indexOf(key))));
87
73
  return index === -1 ? undefined : index + 1;
88
74
  }
89
- /**
90
- * Decides whether install-time commands are routine (D19): every command is an
91
- * inert tool or a package-manager call that runs a script which is itself
92
- * routine — including npm's pre/post hooks for that script — and nothing else
93
- * is present: no network, local files, inline code, code signals, environment
94
- * assignments, redirects, wrappers, or workspace-wide runs. Results are
95
- * memoized per script name, so a layered script graph costs linear time.
96
- */
97
75
  function routineChecker(scripts) {
98
76
  const memo = new Map();
99
77
  const scriptIsRoutine = (name, depth) => {
100
78
  const known = memo.get(name);
101
79
  if (known !== undefined)
102
80
  return known;
103
- memo.set(name, false); // a cycle back here is not routine
81
+ memo.set(name, false);
104
82
  const body = scripts[name];
105
83
  const result = typeof body === "string" &&
106
84
  commandIsRoutine(body, depth + 1) &&
@@ -122,7 +100,6 @@ function routineChecker(scripts) {
122
100
  facts.commands.length === 0) {
123
101
  return false;
124
102
  }
125
- // Every package-manager call must run a script: `npm install x` does not.
126
103
  const pmCalls = facts.commands.filter((name) => PACKAGE_MANAGERS.has(name)).length;
127
104
  if (pmCalls !== facts.runScripts.length)
128
105
  return false;
@@ -132,12 +109,6 @@ function routineChecker(scripts) {
132
109
  };
133
110
  return (command) => commandIsRoutine(command, 0);
134
111
  }
135
- /**
136
- * What a script's command does, including the files and inline code it runs
137
- * and the package.json scripts it calls through `npm run` (with npm's pre/post
138
- * hooks), recursively. Signals are combined across the whole call graph, so
139
- * an attack split across two scripts still blocks. Memoized per script name.
140
- */
141
112
  export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
142
113
  const memo = new Map();
143
114
  const empty = {
@@ -150,7 +121,7 @@ export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
150
121
  const known = memo.get(name);
151
122
  if (known !== undefined)
152
123
  return known;
153
- memo.set(name, empty); // break cycles
124
+ memo.set(name, empty);
154
125
  const body = scripts[name];
155
126
  const result = typeof body !== "string"
156
127
  ? {
@@ -166,8 +137,6 @@ export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
166
137
  return { ...empty, reasons: ["scripts are nested too deeply to follow"] };
167
138
  const facts = analyzeShell(command);
168
139
  if (facts.unparsable) {
169
- // Fail closed: unparsable text that names a download tool could be
170
- // hiding curl … | sh behind a trick the parser does not understand.
171
140
  return facts.mentionsDownloader
172
141
  ? {
173
142
  ...empty,
@@ -254,7 +223,6 @@ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
254
223
  manifest = JSON.parse(stripBom(text));
255
224
  }
256
225
  catch {
257
- // Not swallowed: an unreadable manifest is reported, the scan continues.
258
226
  return [
259
227
  {
260
228
  ruleId: RULE,
@@ -299,7 +267,6 @@ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
299
267
  });
300
268
  }
301
269
  }
302
- // npm runs `node-gyp rebuild` for a package with binding.gyp and no install script of its own.
303
270
  const gyp = posix.join(dir, "binding.gyp");
304
271
  if (!("install" in scripts) &&
305
272
  !("preinstall" in scripts) &&
@@ -313,12 +280,6 @@ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
313
280
  }
314
281
  return findings;
315
282
  }
316
- /**
317
- * Install-time scripts in every package.json outside node_modules (workspaces
318
- * run theirs too). The policy decides which code-signal combinations block and
319
- * which scripts count; package contents use one that tolerates binary
320
- * installers and follows npm's rules for registry dependencies.
321
- */
322
283
  export function lifecycleScriptWith(policy) {
323
284
  return (repo) => repo.entries
324
285
  .filter((e) => e.kind === "file" &&
@@ -1,5 +1,4 @@
1
1
  const SEGMENT = "node_modules";
2
- /** The outermost node_modules directory containing path, or undefined. */
3
2
  function nodeModulesRoot(path) {
4
3
  const segments = path.split("/");
5
4
  const index = segments.indexOf(SEGMENT);
@@ -8,12 +7,6 @@ function nodeModulesRoot(path) {
8
7
  export function isInNodeModules(path) {
9
8
  return nodeModulesRoot(path) !== undefined;
10
9
  }
11
- /**
12
- * croak repo checks a repo before install, so installed dependencies in it are
13
- * a signal on their own: a committed node_modules is a classic hiding place.
14
- * One finding per directory instead of one per file keeps installed projects
15
- * readable.
16
- */
17
10
  export const nodeModulesPresent = (repo) => {
18
11
  const counts = new Map();
19
12
  for (const { path } of repo.entries) {