@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
@@ -12,23 +12,19 @@ const asString = (value) => typeof value === "string" ? value : undefined;
12
12
  const asStrings = (value) => Array.isArray(value)
13
13
  ? value.filter((v) => typeof v === "string")
14
14
  : [];
15
- /** npm's rule: refused by `!value`; if any value is listed without `!`, it must be one of them. */
16
15
  function accepts(list, value) {
17
16
  if (list.includes(`!${value}`))
18
17
  return false;
19
18
  const allowed = list.filter((v) => !v.startsWith("!"));
20
19
  return allowed.length === 0 || allowed.includes(value);
21
20
  }
22
- /** Whether npm would install this entry on the platform (platform packages for other systems are skipped). */
23
21
  export function installsOn(entry, platform) {
24
22
  return accepts(entry.os, platform.os) && accepts(entry.cpu, platform.cpu);
25
23
  }
26
- /** `node_modules/a/node_modules/@s/b` → `@s/b`. */
27
24
  function nameFromPath(path) {
28
25
  const index = path.lastIndexOf("node_modules/");
29
26
  return index === -1 ? path : path.slice(index + "node_modules/".length);
30
27
  }
31
- /** Parses an npm lockfile (v2 or v3). Lockfile v1 has no `packages` map and is rejected. */
32
28
  export function parseLockfile(text) {
33
29
  let data;
34
30
  try {
@@ -45,7 +41,6 @@ export function parseLockfile(text) {
45
41
  return [];
46
42
  if (!isRecord(raw))
47
43
  throw new LockfileError(`entry ${path} is not an object`);
48
- // Without a version croak cannot look the package up; npm always writes one.
49
44
  if (raw.link !== true && typeof raw.version !== "string")
50
45
  throw new LockfileError(`entry ${path} has no version`);
51
46
  return [
@@ -56,8 +51,6 @@ export function parseLockfile(text) {
56
51
  resolved: asString(raw.resolved),
57
52
  integrity: asString(raw.integrity),
58
53
  hasInstallScript: raw.hasInstallScript === true,
59
- // A key outside node_modules is a local folder npm links (a
60
- // workspace member), never a download, unless it claims a source.
61
54
  link: raw.link === true ||
62
55
  (!path.startsWith("node_modules/") &&
63
56
  !path.includes("/node_modules/") &&
@@ -4,7 +4,6 @@ const RECENT_DAYS = 7;
4
4
  const NEW_PACKAGE_DAYS = 30;
5
5
  const DAY_MS = 24 * 60 * 60 * 1000;
6
6
  const normalize = (name) => name.toLowerCase().replace(/[-_.]/g, "");
7
- /** Optimal string alignment distance (Levenshtein plus adjacent transpositions), capped at `cap`. */
8
7
  function distance(a, b, cap = 2) {
9
8
  if (Math.abs(a.length - b.length) >= cap)
10
9
  return cap;
@@ -28,12 +27,6 @@ function distance(a, b, cap = 2) {
28
27
  }
29
28
  return Math.min(d[a.length][b.length] ?? cap, cap);
30
29
  }
31
- /**
32
- * Popular packages a name that npm does not have might have meant (M4): up to
33
- * three, nearest first, within two keystrokes or one separator. Only for
34
- * names that do not exist, where a suggestion cannot be the attack itself;
35
- * an existing look-alike is a finding instead (nearPopularName).
36
- */
37
30
  export function suggestNames(spec) {
38
31
  const at = spec.indexOf("@", 1);
39
32
  const name = (at === -1 ? spec : spec.slice(0, at)).toLowerCase();
@@ -48,20 +41,12 @@ export function suggestNames(spec) {
48
41
  .slice(0, 3)
49
42
  .map(({ popular }) => popular);
50
43
  }
51
- /** The popular package this unscoped name is one keystroke (or one separator) away from. */
52
44
  export function nearPopularName(name) {
53
45
  if (name.startsWith("@") || POPULAR.has(name) || name.length < 5)
54
46
  return undefined;
55
47
  return POPULAR_PACKAGES.find((popular) => popular.length >= 5 &&
56
48
  (normalize(popular) === normalize(name) || distance(popular, name) === 1));
57
49
  }
58
- /**
59
- * Registry signals for one new or changed version. Each signal alone is a
60
- * warning (D25), except a new publisher, which alone is context (D28); two
61
- * patterns block (D26): an install script with dropped
62
- * provenance or a new publisher (a hijacked maintainer account), and a
63
- * typo-squat name on a brand-new package.
64
- */
65
50
  export function metadataFindings(change, facts, now = new Date()) {
66
51
  const { entry } = change;
67
52
  const file = `${entry.name}@${entry.version ?? "?"}`;
@@ -79,21 +64,14 @@ export function metadataFindings(change, facts, now = new Date()) {
79
64
  now.getTime() - facts.created.getTime() < NEW_PACKAGE_DAYS * DAY_MS;
80
65
  const recent = facts.earlier.slice(0, 5);
81
66
  const provenanceDropped = !facts.hasProvenance && recent.some((v) => v.hasProvenance);
82
- // With provenance, the publisher is a CI identity tied to the source repo;
83
- // a different account name there is a pipeline change, not a new person.
84
67
  const newPublisher = !facts.hasProvenance &&
85
68
  facts.publisher !== undefined &&
86
69
  facts.earlier.length > 0 &&
87
70
  !facts.earlier.some((v) => v.publisher === facts.publisher);
88
- // A new publisher only matters with install-time code that is new: the same
89
- // install script as before (sqlite3's prebuild-install) runs files the
90
- // content checks read anyway (D30).
91
71
  const { previous } = facts;
92
72
  const sameScript = previous !== undefined &&
93
73
  facts.installCommands !== "" &&
94
74
  previous.installCommands === facts.installCommands;
95
- // The lockfile's hasInstallScript is the project's (or attacker's) word;
96
- // the registry's manifest says it too, and either one counts.
97
75
  const runsAtInstall = entry.hasInstallScript || facts.installScripts.length > 0;
98
76
  const hijack = runsAtInstall && (provenanceDropped || (newPublisher && !sameScript));
99
77
  const recentRelease = facts.ageDays !== undefined && facts.ageDays < RECENT_DAYS;
@@ -107,10 +85,7 @@ export function metadataFindings(change, facts, now = new Date()) {
107
85
  findings.push(finding("install/provenance", hijack ? "block" : "warn", `has no provenance, although earlier versions were published with it${pattern}`));
108
86
  }
109
87
  if (newPublisher) {
110
- findings.push(finding("install/publisher",
111
- // Alone it is context: maintainers rotate and orgs publish from
112
- // service accounts (microsoft1es, GitHub Actions).
113
- hijack ? "block" : runsAtInstall ? "warn" : "info", `was published by ${facts.publisher}, who did not publish the earlier versions${pattern}${!hijack && runsAtInstall && sameScript && previous !== undefined ? `; it runs the same install script as ${previous.version}` : ""}`));
88
+ findings.push(finding("install/publisher", hijack ? "block" : runsAtInstall ? "warn" : "info", `was published by ${facts.publisher}, who did not publish the earlier versions${pattern}${!hijack && runsAtInstall && sameScript && previous !== undefined ? `; it runs the same install script as ${previous.version}` : ""}`));
114
89
  }
115
90
  const lookalike = nearPopularName(entry.name);
116
91
  if (lookalike !== undefined) {
@@ -126,11 +101,6 @@ export function metadataFindings(change, facts, now = new Date()) {
126
101
  findings.push(finding("install/new-package", "warn", `is a brand-new package (created less than ${NEW_PACKAGE_DAYS} days ago)`));
127
102
  return findings;
128
103
  }
129
- /**
130
- * Dependencies this version adds compared with its previous version. A new
131
- * dependency is context; a new dependency that is itself a brand-new package
132
- * is how the 2026 axios release smuggled in its payload (D27), so it blocks.
133
- */
134
104
  export function addedDependencyFindings(change, facts, createdByName, now = new Date()) {
135
105
  const { previous } = facts;
136
106
  if (previous === undefined)
@@ -1,14 +1,4 @@
1
- /**
2
- * Same package only when it comes from the same place with the same hash: a
3
- * second lodash@4.17.21 from another URL, or an installed copy whose hash
4
- * differs from the plan's, is a different download and is checked.
5
- */
6
1
  const key = (entry) => `${entry.name}@${entry.version ?? "?"} ${entry.resolved ?? ""} ${entry.integrity ?? ""}`;
7
- /**
8
- * Every package version the install would bring that the project did not
9
- * have — direct and transitive, each name@version, source and hash once. A changed version of
10
- * an existing package is reported with the previous version.
11
- */
12
2
  export function diffLockfiles(before, after) {
13
3
  const had = new Set((before ?? []).map(key));
14
4
  const previous = new Map();
@@ -4,12 +4,10 @@ import { parseYamlDocuments, YamlError } from "./yaml.js";
4
4
  function isRecord(value) {
5
5
  return typeof value === "object" && value !== null && !Array.isArray(value);
6
6
  }
7
- /** "nx@21.6.4 || 21.6.5" → "nx"; "@s/x@1" → "@s/x". */
8
7
  const matcherName = (key) => {
9
8
  const at = key.indexOf("@", key.startsWith("@") ? 1 : 0);
10
9
  return (at === -1 ? key : key.slice(0, at)).trim();
11
10
  };
12
- /** Whether the manifest pins a package manager other than exactly this pnpm version. */
13
11
  function pinnedElsewhere(manifest, pnpmVersion) {
14
12
  if (manifest.packageManager !== undefined) {
15
13
  const pin = /^pnpm@(\d+\.\d+\.\d+)(?:\+.*)?$/.exec(String(manifest.packageManager));
@@ -34,9 +32,6 @@ export function pnpmBuildPolicy(files, pnpmVersion) {
34
32
  try {
35
33
  if (files.packageJson !== undefined) {
36
34
  const manifest = JSON.parse(stripBom(files.packageJson));
37
- // The real install runs the pnpm the project pins (corepack, or pnpm's
38
- // own version switching), not necessarily the one croak measured: any
39
- // pin but exactly that version, and every script counts (M6 review).
40
35
  if (isRecord(manifest) && pinnedElsewhere(manifest, pnpmVersion))
41
36
  return undefined;
42
37
  if (isRecord(manifest) && isRecord(manifest.pnpm))
@@ -54,11 +49,9 @@ export function pnpmBuildPolicy(files, pnpmVersion) {
54
49
  }
55
50
  const named = new Set();
56
51
  for (const s of settings) {
57
- // Anything but an explicit false allows every build (YAML has True, TRUE, …).
58
52
  if (s.dangerouslyAllowAllBuilds !== undefined &&
59
53
  !/^(?:false|False|FALSE)$/.test(String(s.dangerouslyAllowAllBuilds)))
60
54
  return undefined;
61
- // A list in a file croak does not read: it could allow anything.
62
55
  if (s.onlyBuiltDependenciesFile !== undefined)
63
56
  return undefined;
64
57
  for (const field of [
@@ -77,7 +70,6 @@ export function pnpmBuildPolicy(files, pnpmVersion) {
77
70
  for (const key of Object.keys(s[field]))
78
71
  named.add(matcherName(key));
79
72
  }
80
- // A glob or pattern croak does not expand could name anything.
81
73
  if ([...named].some((name) => /[*?{}[\]!]/.test(name)))
82
74
  return undefined;
83
75
  return (name) => named.has(name);
@@ -87,11 +79,6 @@ const SCRIPT_RULES = new Set([
87
79
  "install/install-script",
88
80
  "install/new-install-script",
89
81
  ]);
90
- /**
91
- * Warnings about install scripts pnpm will not run become info. Blocks stay:
92
- * a malicious script is evidence about the package, and a later
93
- * `pnpm approve-builds` would run it.
94
- */
95
82
  export function applyBuildPolicy(findings, policy) {
96
83
  return findings.map((f) => {
97
84
  const label = labelOf(f.file);
@@ -1,30 +1,18 @@
1
1
  import { LockfileError } from "./lockfile.js";
2
2
  import { REGISTRY } from "./registry.js";
3
3
  import { parseYamlDocuments, YamlError } from "./yaml.js";
4
- /**
5
- * pnpm-lock.yaml (lockfile v9: pnpm 9 and later, M6, D38) as the same
6
- * LockEntry list the npm lockfile gives, so the rest of croak plan is shared.
7
- * Registry packages carry only an integrity hash: the tarball URL is the
8
- * public registry's, as pnpm itself derives it (the hook refuses other
9
- * registries, D34). Git and tarball resolutions keep their source, so
10
- * install/source reports them; local directories are links. pnpm records no
11
- * hasInstallScript: install scripts come from the registry manifest and the
12
- * tarball instead.
13
- */
14
4
  function isRecord(value) {
15
5
  return typeof value === "object" && value !== null && !Array.isArray(value);
16
6
  }
17
7
  const strings = (value) => Array.isArray(value)
18
8
  ? value.filter((v) => typeof v === "string")
19
9
  : [];
20
- /** "@scope/name@1.2.3" → ["@scope/name", "1.2.3"]; the version may be a URL or path. */
21
10
  function splitKey(key) {
22
11
  const at = key.indexOf("@", key.startsWith("@") ? 1 : 0);
23
12
  if (at <= 0)
24
13
  throw new LockfileError(`package key without a version: ${key}`);
25
14
  return [key.slice(0, at), key.slice(at + 1)];
26
15
  }
27
- /** Strip a snapshot's peer suffix: "react-dom@19.2.0(react@19.3.1)" → "react-dom@19.2.0". */
28
16
  const withoutPeers = (key) => {
29
17
  const open = key.indexOf("(");
30
18
  return open === -1 ? key : key.slice(0, open);
@@ -52,7 +40,6 @@ export function parsePnpmLockfile(text) {
52
40
  throw new LockfileError(`pnpm-lock.yaml version ${String(version)} is not supported: croak reads lockfile v9 (pnpm 9 and later)`);
53
41
  const packages = isRecord(doc.packages) ? doc.packages : {};
54
42
  const snapshots = isRecord(doc.snapshots) ? doc.snapshots : {};
55
- // A package is optional only when every snapshot of it is.
56
43
  const optional = new Map();
57
44
  for (const [key, snap] of Object.entries(snapshots)) {
58
45
  const id = withoutPeers(key);
@@ -77,7 +64,6 @@ export function parsePnpmLockfile(text) {
77
64
  resolved = resolution.tarball;
78
65
  else if (integrity !== undefined)
79
66
  resolved = registryUrl(name, ref);
80
- // A git or tarball dependency's "version" is its source, not a semver.
81
67
  const semver = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(ref);
82
68
  entries.push({
83
69
  path: `pnpm:${key}`,
@@ -1,8 +1,3 @@
1
- /**
2
- * Well-known npm package names, for typo-squat checks. Curated by hand from
3
- * widely depended-on packages; a name one keystroke away from one of these is
4
- * suspicious, the names themselves are not.
5
- */
6
1
  export const POPULAR_PACKAGES = [
7
2
  "react",
8
3
  "react-dom",
@@ -7,7 +7,6 @@ import { stripBom } from "../signals/text.js";
7
7
  import { isPnpmLockfile } from "./pnpm-lockfile.js";
8
8
  import { parseYamlDocuments, YamlError } from "./yaml.js";
9
9
  const PNPM_RULE = "repo/pnpmfile";
10
- /** pnpm also runs the root project's pnpm:devPreinstall before it installs anything. */
11
10
  const PNPM_ROOT_SCRIPT = "pnpm:devPreinstall";
12
11
  const policies = {
13
12
  npm: lifecycleScriptWith({
@@ -25,12 +24,6 @@ function isRecord(value) {
25
24
  return typeof value === "object" && value !== null && !Array.isArray(value);
26
25
  }
27
26
  const outside = (path) => path === ".." || path.startsWith("../") || path.startsWith("/");
28
- /**
29
- * The workspace members a lockfile installs, as folders relative to the
30
- * project (M6 review): npm's `packages` keys outside node_modules, pnpm's
31
- * `importers` other than the root. Both package managers run a member's
32
- * install scripts on a plain install, as they do the root's.
33
- */
34
27
  export function workspaceMembers(lockfile) {
35
28
  let keys = [];
36
29
  if (isPnpmLockfile(lockfile)) {
@@ -95,9 +88,7 @@ export function projectScriptFindings(dir, fs, options = {}) {
95
88
  scripts = data.scripts;
96
89
  }
97
90
  catch {
98
- // The detector reports an unreadable package.json itself.
99
91
  }
100
- // Every local file any script runs (install scripts call others via npm run).
101
92
  const files = new Set(["package.json", "binding.gyp"]);
102
93
  for (const command of Object.values(scripts))
103
94
  if (typeof command === "string")
@@ -116,13 +107,6 @@ export function projectScriptFindings(dir, fs, options = {}) {
116
107
  ];
117
108
  }
118
109
  const PNPMFILES = [".pnpmfile.cjs", ".pnpmfile.mjs"];
119
- /**
120
- * Code pnpm runs on every install of the project, scripts or not (M6, D39):
121
- * the pnpmfile (`.pnpmfile.cjs`, or the file `pnpmfile:` names in
122
- * pnpm-workspace.yaml) and config dependencies, which pnpm installs first and
123
- * whose pnpmfiles it loads. A pnpmfile is a warning, a block when its code
124
- * reaches the network with secrets or eval; config dependencies are a warning.
125
- */
126
110
  export function pnpmfileFindings(dir, fs) {
127
111
  const findings = [];
128
112
  let settings = {};
@@ -144,7 +128,6 @@ export function pnpmfileFindings(dir, fs) {
144
128
  });
145
129
  }
146
130
  }
147
- // One file or a list of them (M6 review).
148
131
  const listed = Array.isArray(settings.pnpmfile)
149
132
  ? settings.pnpmfile
150
133
  : settings.pnpmfile === undefined
@@ -185,8 +168,6 @@ export function pnpmfileFindings(dir, fs) {
185
168
  message: `pnpm runs ${file} on every install${hits.length > 0 ? `: ${describeSignals(hits, file)}` : ""}`,
186
169
  });
187
170
  }
188
- // Patches change a dependency's files after croak checked its tarball, its
189
- // package.json and install scripts included (M6 review).
190
171
  let manifestSettings;
191
172
  const manifest = fs.read(posix.join(dir, "package.json"));
192
173
  if (manifest !== undefined)
@@ -196,7 +177,6 @@ export function pnpmfileFindings(dir, fs) {
196
177
  manifestSettings = data.pnpm;
197
178
  }
198
179
  catch {
199
- // The install itself fails on it; the plan reports the lockfile.
200
180
  }
201
181
  for (const [where, from] of [
202
182
  ["pnpm-workspace.yaml", settings],
@@ -1,7 +1,3 @@
1
- /**
2
- * The workflow file a build started from, inside its repo and without its
3
- * ref: tags change every release, and a renamed repo renames the URI.
4
- */
5
1
  function workflowOf(identity) {
6
2
  const uri = identity.buildConfigUri?.replace(/@[^@]*$/, "");
7
3
  const repo = `${identity.sourceRepositoryUri ?? ""}/`;
@@ -10,12 +6,8 @@ function workflowOf(identity) {
10
6
  const repoIdOf = (identity) => identity.sourceRepositoryId === undefined
11
7
  ? undefined
12
8
  : `${identity.issuer ?? ""}#${identity.sourceRepositoryId}`;
13
- /** How this build differs from the previous version's, if it does. */
14
9
  function buildChanges(now, before, version) {
15
10
  const changes = [];
16
- // Repos get renamed (react now builds from github.com/react/react); the
17
- // numeric id survives a rename and changes when a name is re-registered.
18
- // An id is only unique at its issuer (GitHub's and GitLab's overlap).
19
11
  const nowId = repoIdOf(now);
20
12
  const beforeId = repoIdOf(before);
21
13
  const otherRepo = nowId !== undefined && beforeId !== undefined
@@ -27,24 +19,11 @@ function buildChanges(now, before, version) {
27
19
  changes.push(`was built by the workflow ${workflowOf(now) ?? "(unnamed)"}, not by ${workflowOf(before) ?? "the workflow"}, which built ${version}`);
28
20
  return changes;
29
21
  }
30
- /**
31
- * The version to compare a planned one with: its semver predecessor when that
32
- * has provenance, else the newest settled version that does. Comparing only
33
- * with the predecessor would let a release without provenance in between
34
- * hide another repository's build (M7 review, D45).
35
- */
36
22
  export function provenanceBaseline(facts) {
37
23
  return facts.previous?.hasProvenance === true
38
24
  ? facts.previous
39
25
  : facts.earlier.find((v) => v.hasProvenance);
40
26
  }
41
- /**
42
- * Provenance signals for one planned version (M7, D42). A claim that does
43
- * not verify blocks; one croak cannot check warns. A build from another
44
- * repository or workflow than the baseline's warns, and blocks with an
45
- * install script that is new or changed since the baseline (as in D30).
46
- * Provenance never lowers another finding.
47
- */
48
27
  export function provenanceFindings(change, current, baseline, installCommands) {
49
28
  const file = `${change.entry.name}@${change.entry.version ?? "?"}`;
50
29
  const finding = (severity, message) => ({
@@ -1,14 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- /**
3
- * A minimal npm registry client: one packument per package, read with a time
4
- * limit and a size limit. Package names come from untrusted lockfiles, so they
5
- * are validated against npm's naming rules before they become a URL.
6
- */
7
2
  export const REGISTRY = "https://registry.npmjs.org";
8
- // @prisma/client's package document alone is 68 MB (2026-10).
9
3
  const DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
10
4
  const DEFAULT_TIMEOUT_MS = 15_000;
11
- // Legacy packages may contain capitals (JSONStream); new ones may not.
12
5
  const NAME = /^(?:@[a-zA-Z0-9-~][a-zA-Z0-9-._~]*\/)?[a-zA-Z0-9-~][a-zA-Z0-9-._~]*$/;
13
6
  const DAY_MS = 24 * 60 * 60 * 1000;
14
7
  const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
@@ -37,7 +30,6 @@ async function readCappedBytes(response, maxBytes) {
37
30
  chunk = await reader.read();
38
31
  }
39
32
  catch (error) {
40
- // A timeout or a reset after the headers: the check is incomplete, not a crash.
41
33
  throw new RegistryError(`reading the response: ${error instanceof Error ? error.message : String(error)}`);
42
34
  }
43
35
  const { done, value } = chunk;
@@ -55,7 +47,6 @@ async function readCappedBytes(response, maxBytes) {
55
47
  async function readCapped(response, maxBytes) {
56
48
  return (await readCappedBytes(response, maxBytes)).toString("utf8");
57
49
  }
58
- /** A JSON document from the registry, or "not-found" on 404. */
59
50
  async function getJson(url, label, options, maxBytes) {
60
51
  const doFetch = options.fetch ?? fetch;
61
52
  let response;
@@ -80,18 +71,11 @@ async function getJson(url, label, options, maxBytes) {
80
71
  throw new RegistryError(`${label}: registry response is not JSON`);
81
72
  }
82
73
  }
83
- /** The package document, or "not-found" when the registry has no such package. */
84
74
  export async function fetchPackument(name, options = {}) {
85
75
  return getJson(packageUrl(name), name, options, options.maxBytes ?? DEFAULT_MAX_BYTES);
86
76
  }
87
- // A provenance bundle is ~10 KB; a version has two attestations.
88
77
  const MAX_ATTESTATIONS_BYTES = 2 * 1024 * 1024;
89
78
  const VERSION = /^[0-9A-Za-z.+-]{1,256}$/;
90
- /**
91
- * A version's SLSA provenance bundle from the registry's attestations
92
- * endpoint, or "missing" when there is none. The URL is built here, never
93
- * taken from the packument's dist.attestations.url.
94
- */
95
79
  export async function fetchProvenanceBundle(name, version, options = {}) {
96
80
  if (!VERSION.test(version) || version.includes(".."))
97
81
  throw new RegistryError(`not a valid npm version: ${JSON.stringify(version)}`);
@@ -112,10 +96,8 @@ const date = (value) => {
112
96
  const parsed = new Date(value);
113
97
  return Number.isNaN(parsed.getTime()) ? undefined : parsed;
114
98
  };
115
- /** Days a version must have been public to count as history (D25's 7 days). */
116
99
  export const SETTLED_DAYS = 7;
117
100
  const SEMVER = /^(\d{1,15})\.(\d{1,15})\.(\d{1,15})(?:-([0-9A-Za-z.-]{1,100}))?(?:\+.*)?$/;
118
- /** Semver order (prereleases before their release); undefined if either is not semver. */
119
101
  export function compareVersions(a, b) {
120
102
  const x = SEMVER.exec(a);
121
103
  const y = SEMVER.exec(b);
@@ -135,7 +117,6 @@ export function compareVersions(a, b) {
135
117
  return -1;
136
118
  return px < py ? -1 : 1;
137
119
  }
138
- /** The highest settled version below `version`, else the newest settled one. */
139
120
  function predecessor(version, earlier) {
140
121
  const below = earlier.filter((v) => (compareVersions(v.version, version) ?? 1) < 0);
141
122
  return (below.reduce((best, v) => best === undefined ||
@@ -161,7 +142,6 @@ function versionInfo(meta) {
161
142
  dependencies: [...new Set(deps)].sort(),
162
143
  };
163
144
  }
164
- /** What the registry says about one version, or undefined if it does not list it. */
165
145
  export function versionFacts(packument, version, now = new Date()) {
166
146
  if (!isRecord(packument) ||
167
147
  !isRecord(packument.versions) ||
@@ -193,7 +173,6 @@ export function versionFacts(packument, version, now = new Date()) {
193
173
  previous: predecessor(version, earlier),
194
174
  };
195
175
  }
196
- /** A version's tarball URL and integrity, from its packument (the LLM diff's "before"). */
197
176
  export function tarballOf(packument, version) {
198
177
  if (!isRecord(packument) || !isRecord(packument.versions))
199
178
  return undefined;
@@ -206,14 +185,12 @@ export function tarballOf(packument, version) {
206
185
  integrity: typeof dist.integrity === "string" ? dist.integrity : undefined,
207
186
  };
208
187
  }
209
- /** The downloaded tarball does not match the lockfile's integrity hash. */
210
188
  export class IntegrityError extends Error {
211
189
  constructor(message) {
212
190
  super(message);
213
191
  this.name = "IntegrityError";
214
192
  }
215
193
  }
216
- /** Checks data against an SRI string ("sha512-…"), using the strongest algorithm it lists. */
217
194
  export function verifyIntegrity(data, integrity) {
218
195
  const hashes = new Map(integrity
219
196
  .trim()
@@ -229,12 +206,6 @@ export function verifyIntegrity(data, integrity) {
229
206
  if (actual !== hashes.get(algorithm))
230
207
  throw new IntegrityError(`${algorithm} does not match the lockfile`);
231
208
  }
232
- /**
233
- * The tarball URL if `resolved` points at the public registry over https,
234
- * with no credentials or explicit port. One predicate for what croak
235
- * downloads, checks online, and reports as a non-registry source, so a
236
- * package can never fall between them unchecked.
237
- */
238
209
  export function registryTarball(resolved) {
239
210
  if (resolved === undefined || !URL.canParse(resolved))
240
211
  return undefined;
@@ -247,10 +218,6 @@ export function registryTarball(resolved) {
247
218
  ? url
248
219
  : undefined;
249
220
  }
250
- /**
251
- * Downloads a tarball from the public registry (https only) with a timeout
252
- * and a 100 MB cap, and verifies it against the lockfile's integrity hash.
253
- */
254
221
  export async function fetchTarball(url, integrity, options = {}) {
255
222
  const tarball = registryTarball(url);
256
223
  if (tarball === undefined)
@@ -1,15 +1,5 @@
1
1
  import { stripBom } from "../signals/text.js";
2
2
  import { parseYamlDocuments } from "./yaml.js";
3
- /**
4
- * What the planning wrapper may hand to a package manager from an untrusted
5
- * project (M6, D38). Planning must run no project code, but pnpm runs code
6
- * before it resolves anything: corepack downloads and runs the pnpm version a
7
- * project's `packageManager` names (checked: pnpm 12 ran pnpm 9.15.0, even
8
- * with manage-package-manager-versions=false), and pnpm-workspace.yaml can
9
- * load pnpmfiles and config dependencies. So the root manifest loses its
10
- * package-manager pins, and the workspace file keeps only settings that shape
11
- * resolution. Both come out as JSON, which YAML reads too.
12
- */
13
3
  export class SanitizeError extends Error {
14
4
  constructor(message) {
15
5
  super(message);
@@ -19,7 +9,6 @@ export class SanitizeError extends Error {
19
9
  function isRecord(value) {
20
10
  return typeof value === "object" && value !== null && !Array.isArray(value);
21
11
  }
22
- /** The root package.json without the fields that make corepack fetch a package manager. */
23
12
  export function sanitizeRootManifest(text) {
24
13
  let manifest;
25
14
  try {
@@ -31,7 +20,6 @@ export function sanitizeRootManifest(text) {
31
20
  if (!isRecord(manifest))
32
21
  throw new SanitizeError("package.json is not an object");
33
22
  const { packageManager: _pm, devEngines: _de, pnpm, ...rest } = manifest;
34
- // pnpm 10 reads settings from the root manifest's "pnpm" field too.
35
23
  const kept = isRecord(pnpm)
36
24
  ? {
37
25
  ...rest,
@@ -40,7 +28,6 @@ export function sanitizeRootManifest(text) {
40
28
  : rest;
41
29
  return `${JSON.stringify(kept, null, 2)}\n`;
42
30
  }
43
- /** Settings that only shape what pnpm resolves; nothing that loads or runs code, or moves the registry. */
44
31
  const WORKSPACE_KEYS = new Set([
45
32
  "packages",
46
33
  "catalog",
@@ -58,7 +45,6 @@ const WORKSPACE_KEYS = new Set([
58
45
  "supportedArchitectures",
59
46
  "ignoredOptionalDependencies",
60
47
  ]);
61
- /** YAML scalars come back as strings; pnpm wants booleans where YAML had them. */
62
48
  function typed(value) {
63
49
  if (value === "true" || value === "True" || value === "TRUE")
64
50
  return true;
@@ -13,15 +13,9 @@ import { provenanceBaseline, provenanceFindings, } from "./provenance.js";
13
13
  import { fetchPackument, fetchProvenanceBundle, fetchTarball, IntegrityError, RegistryError, registryTarball, versionFacts, } from "./registry.js";
14
14
  import { signatureFindings } from "./signature.js";
15
15
  import { snapshotTarball, TarballError } from "./tar.js";
16
- /** An npm package-lock.json or a pnpm-lock.yaml, told apart by their first lines. */
17
16
  export function parseAnyLockfile(text) {
18
17
  return isPnpmLockfile(text) ? parsePnpmLockfile(text) : parseLockfile(text);
19
18
  }
20
- /**
21
- * Judges what an install would bring in, from the project's lockfile before
22
- * and the lockfile npm planned (`--package-lock-only --ignore-scripts`).
23
- * croak itself runs no package manager; it only reads the two files.
24
- */
25
19
  export function planInstall(beforeText, afterText, detectors = installDetectors) {
26
20
  const started = performance.now();
27
21
  const parse = (text, which) => {
@@ -58,15 +52,9 @@ export function planInstall(beforeText, afterText, detectors = installDetectors)
58
52
  }
59
53
  }
60
54
  const DEFAULT_MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024;
61
- /** The plan is too large to inspect within the download budget. */
62
55
  class BudgetError extends Error {
63
56
  }
64
57
  const keyOf = (s) => `${s.name}@${s.version}`;
65
- /**
66
- * Provenance findings (D42, D43) for planned versions the registry lists
67
- * with provenance: each one's bundle, and its previous version's, fetched
68
- * and verified, then compared.
69
- */
70
58
  async function provenanceOf(known, options, fetchOptions) {
71
59
  const plans = known.flatMap(({ change, facts }) => {
72
60
  if (facts === undefined || facts === "not-found" || !facts.hasProvenance)
@@ -81,8 +69,6 @@ async function provenanceOf(known, options, fetchOptions) {
81
69
  const previous = before === undefined
82
70
  ? undefined
83
71
  : subject(before.version, before.integrity);
84
- // Both bundles are immutable for these tarballs, and so are both
85
- // versions' install scripts: the findings can be remembered.
86
72
  const label = `provenance ${keyOf(current)} after ${previous === undefined ? "none" : `${keyOf(previous)} ${previous.integrity}`}`;
87
73
  const cached = options.cache?.get(current.integrity, label);
88
74
  return [{ change, facts, before, current, previous, label, cached }];
@@ -116,13 +102,11 @@ async function provenanceOf(known, options, fetchOptions) {
116
102
  provenance: then,
117
103
  installCommands: before.installCommands,
118
104
  }, facts.installCommands);
119
- // A bundle the registry did not serve may be served next time.
120
105
  if (now !== "missing" && then !== "missing")
121
106
  options.cache?.set(current.integrity, plan.label, findings);
122
107
  return findings;
123
108
  });
124
109
  }
125
- /** Runs `work` over `items` with at most `limit` in flight. */
126
110
  async function pool(items, limit, work) {
127
111
  const results = new Array(items.length);
128
112
  let next = 0;
@@ -135,11 +119,6 @@ async function pool(items, limit, work) {
135
119
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
136
120
  return results;
137
121
  }
138
- /**
139
- * planInstall plus npm registry signals (D26) for every new or changed version
140
- * that comes from the public registry. Any registry failure makes the whole
141
- * check incomplete: an agent must not proceed on a partial answer.
142
- */
143
122
  export async function planInstallOnline(beforeText, afterText, options = {}) {
144
123
  const started = performance.now();
145
124
  const local = planInstall(beforeText, afterText);
@@ -147,7 +126,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
147
126
  return local;
148
127
  const before = beforeText === undefined ? undefined : parseAnyLockfile(beforeText);
149
128
  const fromRegistry = diffLockfiles(before, parseAnyLockfile(afterText)).filter((c) => !c.entry.link && registryTarball(c.entry.resolved) !== undefined);
150
- // npm skips platform packages for other systems, so they are not checked.
151
129
  const platform = options.platform ?? {
152
130
  os: process.platform,
153
131
  cpu: process.arch,
@@ -193,7 +171,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
193
171
  : signatureFindings(c, facts, options.registryKeys);
194
172
  return [...metadataFindings(c, facts, now), ...signature, ...deps];
195
173
  });
196
- // Both only read the registry; neither waits for the other.
197
174
  const [provenance, content] = await Promise.all([
198
175
  provenanceOf(known, options, fetchOptions),
199
176
  options.content === false ? [] : contentOf(changes, options),
@@ -205,7 +182,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
205
182
  ...provenance,
206
183
  ...content,
207
184
  ];
208
- // Before the LLM step: a script pnpm will not run needs no second opinion.
209
185
  const ruled = options.builds === undefined
210
186
  ? all
211
187
  : applyBuildPolicy(all, options.builds);
@@ -247,7 +223,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
247
223
  throw error;
248
224
  }
249
225
  }
250
- /** Any listed version, to read package-level facts such as the creation date. */
251
226
  function firstVersion(doc) {
252
227
  const versions = typeof doc === "object" && doc !== null && "versions" in doc
253
228
  ? doc.versions
@@ -256,11 +231,6 @@ function firstVersion(doc) {
256
231
  ? (Object.keys(versions)[0] ?? "")
257
232
  : "";
258
233
  }
259
- /**
260
- * Downloads each registry tarball (verified against the lockfile's integrity),
261
- * reads it in memory, and runs the content detectors. A tampered tarball is a
262
- * block; a plan over the download budget is incomplete, never partially clean.
263
- */
264
234
  async function contentOf(changes, options) {
265
235
  const budget = options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
266
236
  let downloaded = 0;
@@ -293,7 +263,6 @@ async function contentOf(changes, options) {
293
263
  if (downloaded > budget)
294
264
  throw new BudgetError(`the plan downloads more than ${budget} bytes of tarballs`);
295
265
  const findings = contentFindings(label, snapshotTarball(data));
296
- // Only after the download verified against this hash and was read whole.
297
266
  if (entry.integrity !== undefined)
298
267
  options.cache?.set(entry.integrity, label, findings);
299
268
  return findings;