@vola-trebla/croak 0.0.0-stage → 0.1.0

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -2
  3. package/dist/cli.js +373 -0
  4. package/dist/crypto/der.js +136 -0
  5. package/dist/crypto/fields.js +60 -0
  6. package/dist/crypto/registry-signature.js +88 -0
  7. package/dist/crypto/rekor.js +70 -0
  8. package/dist/crypto/sigstore.js +121 -0
  9. package/dist/detectors/git-config.js +137 -0
  10. package/dist/detectors/hidden-code.js +58 -0
  11. package/dist/detectors/ide-autorun.js +136 -0
  12. package/dist/detectors/index.js +20 -0
  13. package/dist/detectors/lifecycle-script.js +337 -0
  14. package/dist/detectors/node-modules-present.js +32 -0
  15. package/dist/detectors/obfuscation.js +124 -0
  16. package/dist/detectors/unscanned.js +41 -0
  17. package/dist/hook/hook.js +391 -0
  18. package/dist/hook/install-commands.js +927 -0
  19. package/dist/install/content-cache.js +94 -0
  20. package/dist/install/content.js +42 -0
  21. package/dist/install/detectors.js +79 -0
  22. package/dist/install/installed.js +99 -0
  23. package/dist/install/lockfile.js +72 -0
  24. package/dist/install/metadata.js +159 -0
  25. package/dist/install/plan.js +31 -0
  26. package/dist/install/pnpm-builds.js +111 -0
  27. package/dist/install/pnpm-lockfile.js +102 -0
  28. package/dist/install/popular.js +177 -0
  29. package/dist/install/project.js +223 -0
  30. package/dist/install/provenance.js +77 -0
  31. package/dist/install/registry.js +274 -0
  32. package/dist/install/report.js +26 -0
  33. package/dist/install/sanitize.js +88 -0
  34. package/dist/install/scan.js +302 -0
  35. package/dist/install/signature.js +62 -0
  36. package/dist/install/tar.js +178 -0
  37. package/dist/install/workspace.js +31 -0
  38. package/dist/install/yaml.js +282 -0
  39. package/dist/llm/client.js +22 -0
  40. package/dist/llm/cost.js +26 -0
  41. package/dist/llm/diff.js +114 -0
  42. package/dist/llm/escalate.js +14 -0
  43. package/dist/llm/plan-step.js +91 -0
  44. package/dist/llm/select.js +21 -0
  45. package/dist/llm/triage.js +111 -0
  46. package/dist/repo-report.js +95 -0
  47. package/dist/repo-snapshot.js +157 -0
  48. package/dist/scan.js +48 -0
  49. package/dist/signals/code.js +136 -0
  50. package/dist/signals/git-config.js +58 -0
  51. package/dist/signals/hidden.js +77 -0
  52. package/dist/signals/jsonc.js +100 -0
  53. package/dist/signals/obfuscation.js +98 -0
  54. package/dist/signals/shell.js +717 -0
  55. package/dist/signals/text.js +2 -0
  56. package/dist/ui.js +12 -0
  57. package/dist/verdict.js +20 -0
  58. package/package.json +71 -4
  59. package/scripts/croak-hook.sh +59 -0
  60. package/scripts/croak-install.sh +262 -0
@@ -0,0 +1,111 @@
1
+ import { labelOf } from "../llm/select.js";
2
+ import { stripBom } from "../signals/text.js";
3
+ import { parseYamlDocuments, YamlError } from "./yaml.js";
4
+ function isRecord(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ /** "nx@21.6.4 || 21.6.5" → "nx"; "@s/x@1" → "@s/x". */
8
+ const matcherName = (key) => {
9
+ const at = key.indexOf("@", key.startsWith("@") ? 1 : 0);
10
+ return (at === -1 ? key : key.slice(0, at)).trim();
11
+ };
12
+ /** Whether the manifest pins a package manager other than exactly this pnpm version. */
13
+ function pinnedElsewhere(manifest, pnpmVersion) {
14
+ if (manifest.packageManager !== undefined) {
15
+ const pin = /^pnpm@(\d+\.\d+\.\d+)(?:\+.*)?$/.exec(String(manifest.packageManager));
16
+ if (pin?.[1] !== pnpmVersion)
17
+ return true;
18
+ }
19
+ if (isRecord(manifest.devEngines) &&
20
+ manifest.devEngines.packageManager !== undefined) {
21
+ const pm = manifest.devEngines.packageManager;
22
+ if (!isRecord(pm) ||
23
+ pm.name !== "pnpm" ||
24
+ String(pm.version ?? "") !== pnpmVersion)
25
+ return true;
26
+ }
27
+ return false;
28
+ }
29
+ export function pnpmBuildPolicy(files, pnpmVersion) {
30
+ const major = Number(/^(\d+)\./.exec(pnpmVersion)?.[1] ?? Number.NaN);
31
+ if (!(major >= 10))
32
+ return undefined;
33
+ const settings = [];
34
+ try {
35
+ if (files.packageJson !== undefined) {
36
+ 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
+ if (isRecord(manifest) && pinnedElsewhere(manifest, pnpmVersion))
41
+ return undefined;
42
+ if (isRecord(manifest) && isRecord(manifest.pnpm))
43
+ settings.push(manifest.pnpm);
44
+ }
45
+ if (files.workspaceYaml !== undefined)
46
+ for (const doc of parseYamlDocuments(files.workspaceYaml))
47
+ if (isRecord(doc))
48
+ settings.push(doc);
49
+ }
50
+ catch (error) {
51
+ if (error instanceof SyntaxError || error instanceof YamlError)
52
+ return undefined;
53
+ throw error;
54
+ }
55
+ const named = new Set();
56
+ for (const s of settings) {
57
+ // Anything but an explicit false allows every build (YAML has True, TRUE, …).
58
+ if (s.dangerouslyAllowAllBuilds !== undefined &&
59
+ !/^(?:false|False|FALSE)$/.test(String(s.dangerouslyAllowAllBuilds)))
60
+ return undefined;
61
+ // A list in a file croak does not read: it could allow anything.
62
+ if (s.onlyBuiltDependenciesFile !== undefined)
63
+ return undefined;
64
+ for (const field of [
65
+ "onlyBuiltDependencies",
66
+ "neverBuiltDependencies",
67
+ "ignoredBuiltDependencies",
68
+ ]) {
69
+ const list = s[field];
70
+ if (Array.isArray(list))
71
+ for (const item of list)
72
+ if (typeof item === "string")
73
+ named.add(item);
74
+ }
75
+ for (const field of ["allowBuilds", "permissions"])
76
+ if (isRecord(s[field]))
77
+ for (const key of Object.keys(s[field]))
78
+ named.add(matcherName(key));
79
+ }
80
+ // A glob or pattern croak does not expand could name anything.
81
+ if ([...named].some((name) => /[*?{}[\]!]/.test(name)))
82
+ return undefined;
83
+ return (name) => named.has(name);
84
+ }
85
+ const SCRIPT_RULES = new Set([
86
+ "repo/lifecycle-script",
87
+ "install/install-script",
88
+ "install/new-install-script",
89
+ ]);
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
+ export function applyBuildPolicy(findings, policy) {
96
+ return findings.map((f) => {
97
+ const label = labelOf(f.file);
98
+ if (label === undefined ||
99
+ f.severity !== "warn" ||
100
+ !SCRIPT_RULES.has(f.ruleId))
101
+ return f;
102
+ const name = label.slice(0, label.lastIndexOf("@"));
103
+ return policy(name)
104
+ ? f
105
+ : {
106
+ ...f,
107
+ severity: "info",
108
+ message: `${f.message} (pnpm will not run it: the project does not allow builds for ${name})`,
109
+ };
110
+ });
111
+ }
@@ -0,0 +1,102 @@
1
+ import { LockfileError } from "./lockfile.js";
2
+ import { REGISTRY } from "./registry.js";
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
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ const strings = (value) => Array.isArray(value)
18
+ ? value.filter((v) => typeof v === "string")
19
+ : [];
20
+ /** "@scope/name@1.2.3" → ["@scope/name", "1.2.3"]; the version may be a URL or path. */
21
+ function splitKey(key) {
22
+ const at = key.indexOf("@", key.startsWith("@") ? 1 : 0);
23
+ if (at <= 0)
24
+ throw new LockfileError(`package key without a version: ${key}`);
25
+ return [key.slice(0, at), key.slice(at + 1)];
26
+ }
27
+ /** Strip a snapshot's peer suffix: "react-dom@19.2.0(react@19.3.1)" → "react-dom@19.2.0". */
28
+ const withoutPeers = (key) => {
29
+ const open = key.indexOf("(");
30
+ return open === -1 ? key : key.slice(0, open);
31
+ };
32
+ const registryUrl = (name, version) => `${REGISTRY}/${name}/-/${name.slice(name.lastIndexOf("/") + 1)}-${version}.tgz`;
33
+ export function isPnpmLockfile(text) {
34
+ return /^(?:---\s*\n)?\s*lockfileVersion:\s*['"]?\d/.test(text);
35
+ }
36
+ export function parsePnpmLockfile(text) {
37
+ let docs;
38
+ try {
39
+ docs = parseYamlDocuments(text);
40
+ }
41
+ catch (error) {
42
+ if (error instanceof YamlError)
43
+ throw new LockfileError(`pnpm-lock.yaml: ${error.message}`);
44
+ throw error;
45
+ }
46
+ const entries = [];
47
+ for (const doc of docs) {
48
+ if (!isRecord(doc))
49
+ throw new LockfileError("pnpm-lock.yaml: a document is not a mapping");
50
+ const version = doc.lockfileVersion;
51
+ if (typeof version !== "string" || !/^9\.\d+$/.test(version))
52
+ throw new LockfileError(`pnpm-lock.yaml version ${String(version)} is not supported: croak reads lockfile v9 (pnpm 9 and later)`);
53
+ const packages = isRecord(doc.packages) ? doc.packages : {};
54
+ const snapshots = isRecord(doc.snapshots) ? doc.snapshots : {};
55
+ // A package is optional only when every snapshot of it is.
56
+ const optional = new Map();
57
+ for (const [key, snap] of Object.entries(snapshots)) {
58
+ const id = withoutPeers(key);
59
+ const isOptional = isRecord(snap) && snap.optional === "true";
60
+ optional.set(id, (optional.get(id) ?? true) && isOptional);
61
+ }
62
+ for (const [key, raw] of Object.entries(packages)) {
63
+ if (!isRecord(raw))
64
+ throw new LockfileError(`pnpm-lock.yaml: package ${key} is not a mapping`);
65
+ const [name, ref] = splitKey(key);
66
+ const resolution = isRecord(raw.resolution) ? raw.resolution : {};
67
+ const integrity = typeof resolution.integrity === "string"
68
+ ? resolution.integrity
69
+ : undefined;
70
+ let resolved;
71
+ let link = false;
72
+ if (typeof resolution.directory === "string")
73
+ link = true;
74
+ else if (resolution.type === "git" && typeof resolution.repo === "string")
75
+ resolved = `git+${resolution.repo}#${typeof resolution.commit === "string" ? resolution.commit : ""}`;
76
+ else if (typeof resolution.tarball === "string")
77
+ resolved = resolution.tarball;
78
+ else if (integrity !== undefined)
79
+ resolved = registryUrl(name, ref);
80
+ // A git or tarball dependency's "version" is its source, not a semver.
81
+ const semver = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(ref);
82
+ entries.push({
83
+ path: `pnpm:${key}`,
84
+ name,
85
+ version: semver
86
+ ? ref
87
+ : typeof raw.version === "string"
88
+ ? raw.version
89
+ : ref,
90
+ resolved,
91
+ integrity,
92
+ hasInstallScript: raw.requiresBuild === "true",
93
+ link,
94
+ dev: false,
95
+ optional: optional.get(key) ?? false,
96
+ os: strings(raw.os),
97
+ cpu: strings(raw.cpu),
98
+ });
99
+ }
100
+ }
101
+ return entries;
102
+ }
@@ -0,0 +1,177 @@
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
+ export const POPULAR_PACKAGES = [
7
+ "react",
8
+ "react-dom",
9
+ "react-router",
10
+ "react-router-dom",
11
+ "redux",
12
+ "react-redux",
13
+ "next",
14
+ "vue",
15
+ "vuex",
16
+ "vue-router",
17
+ "nuxt",
18
+ "svelte",
19
+ "angular",
20
+ "preact",
21
+ "solid-js",
22
+ "lit",
23
+ "jquery",
24
+ "lodash",
25
+ "lodash-es",
26
+ "underscore",
27
+ "ramda",
28
+ "moment",
29
+ "dayjs",
30
+ "date-fns",
31
+ "luxon",
32
+ "axios",
33
+ "node-fetch",
34
+ "got",
35
+ "ky",
36
+ "superagent",
37
+ "request",
38
+ "express",
39
+ "koa",
40
+ "fastify",
41
+ "hapi",
42
+ "hono",
43
+ "nestjs",
44
+ "body-parser",
45
+ "cors",
46
+ "helmet",
47
+ "morgan",
48
+ "cookie-parser",
49
+ "multer",
50
+ "passport",
51
+ "jsonwebtoken",
52
+ "bcrypt",
53
+ "bcryptjs",
54
+ "uuid",
55
+ "nanoid",
56
+ "dotenv",
57
+ "chalk",
58
+ "commander",
59
+ "yargs",
60
+ "inquirer",
61
+ "ora",
62
+ "debug",
63
+ "minimist",
64
+ "glob",
65
+ "rimraf",
66
+ "mkdirp",
67
+ "fs-extra",
68
+ "chokidar",
69
+ "semver",
70
+ "cross-env",
71
+ "concurrently",
72
+ "nodemon",
73
+ "pm2",
74
+ "typescript",
75
+ "tslib",
76
+ "ts-node",
77
+ "tsx",
78
+ "esbuild",
79
+ "vite",
80
+ "webpack",
81
+ "rollup",
82
+ "parcel",
83
+ "babel-core",
84
+ "core-js",
85
+ "regenerator-runtime",
86
+ "postcss",
87
+ "autoprefixer",
88
+ "tailwindcss",
89
+ "sass",
90
+ "less",
91
+ "styled-components",
92
+ "classnames",
93
+ "clsx",
94
+ "prop-types",
95
+ "eslint",
96
+ "prettier",
97
+ "jest",
98
+ "mocha",
99
+ "chai",
100
+ "sinon",
101
+ "vitest",
102
+ "playwright",
103
+ "puppeteer",
104
+ "cypress",
105
+ "supertest",
106
+ "nock",
107
+ "mongoose",
108
+ "mongodb",
109
+ "mysql",
110
+ "mysql2",
111
+ "pg",
112
+ "sequelize",
113
+ "typeorm",
114
+ "prisma",
115
+ "knex",
116
+ "redis",
117
+ "ioredis",
118
+ "graphql",
119
+ "apollo-server",
120
+ "socket.io",
121
+ "ws",
122
+ "zod",
123
+ "yup",
124
+ "joi",
125
+ "ajv",
126
+ "immer",
127
+ "rxjs",
128
+ "async",
129
+ "bluebird",
130
+ "q",
131
+ "colors",
132
+ "picocolors",
133
+ "kleur",
134
+ "chalk-template",
135
+ "execa",
136
+ "shelljs",
137
+ "cheerio",
138
+ "jsdom",
139
+ "marked",
140
+ "highlight.js",
141
+ "prismjs",
142
+ "sharp",
143
+ "jimp",
144
+ "canvas",
145
+ "pdfkit",
146
+ "xlsx",
147
+ "aws-sdk",
148
+ "firebase",
149
+ "stripe",
150
+ "openai",
151
+ "langchain",
152
+ "electron",
153
+ "husky",
154
+ "lint-staged",
155
+ "nx",
156
+ "turbo",
157
+ "lerna",
158
+ "yarn",
159
+ "pnpm",
160
+ "npm",
161
+ "node-gyp",
162
+ "nan",
163
+ "bindings",
164
+ "prebuild-install",
165
+ "jscodeshift",
166
+ "babel-loader",
167
+ "css-loader",
168
+ "style-loader",
169
+ "html-webpack-plugin",
170
+ "dotenv-expand",
171
+ "qs",
172
+ "form-data",
173
+ "mime",
174
+ "mime-types",
175
+ "iconv-lite",
176
+ "safe-buffer",
177
+ ];
@@ -0,0 +1,223 @@
1
+ import { posix } from "node:path";
2
+ import { INSTALL_SCRIPTS, lifecycleScriptWith, } from "../detectors/lifecycle-script.js";
3
+ import { MAX_FILE_BYTES, } from "../repo-snapshot.js";
4
+ import { codeSignals, describeSignals, isSuspicious } from "../signals/code.js";
5
+ import { analyzeShell } from "../signals/shell.js";
6
+ import { stripBom } from "../signals/text.js";
7
+ import { isPnpmLockfile } from "./pnpm-lockfile.js";
8
+ import { parseYamlDocuments, YamlError } from "./yaml.js";
9
+ const PNPM_RULE = "repo/pnpmfile";
10
+ /** pnpm also runs the root project's pnpm:devPreinstall before it installs anything. */
11
+ const PNPM_ROOT_SCRIPT = "pnpm:devPreinstall";
12
+ const policies = {
13
+ npm: lifecycleScriptWith({
14
+ suspicious: isSuspicious,
15
+ scripts: INSTALL_SCRIPTS,
16
+ manifests: "repo",
17
+ }),
18
+ pnpm: lifecycleScriptWith({
19
+ suspicious: isSuspicious,
20
+ scripts: [...INSTALL_SCRIPTS, PNPM_ROOT_SCRIPT],
21
+ manifests: "repo",
22
+ }),
23
+ };
24
+ function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ 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
+ export function workspaceMembers(lockfile) {
35
+ let keys = [];
36
+ if (isPnpmLockfile(lockfile)) {
37
+ const [doc] = parseYamlDocuments(lockfile);
38
+ if (isRecord(doc) && isRecord(doc.importers))
39
+ keys = Object.keys(doc.importers);
40
+ }
41
+ else {
42
+ const data = JSON.parse(stripBom(lockfile));
43
+ if (isRecord(data) && isRecord(data.packages))
44
+ keys = Object.entries(data.packages)
45
+ .filter(([key, raw]) => !key.startsWith("node_modules/") &&
46
+ !key.includes("/node_modules/") &&
47
+ isRecord(raw) &&
48
+ raw.resolved === undefined)
49
+ .map(([key]) => key);
50
+ }
51
+ return [
52
+ ...new Set(keys.map((k) => posix.normalize(k)).filter((k) => k !== ".")),
53
+ ];
54
+ }
55
+ export function projectScriptFindings(dir, fs, options = {}) {
56
+ const findings = [];
57
+ const entries = [];
58
+ const texts = new Map();
59
+ const add = (path) => {
60
+ const at = fs.kind(posix.join(dir, path));
61
+ if (at === undefined || at.kind === "other")
62
+ return;
63
+ if (at.kind === "symlink") {
64
+ entries.push({ path, kind: "symlink", size: 0 });
65
+ return;
66
+ }
67
+ const oversized = at.size > MAX_FILE_BYTES;
68
+ entries.push({
69
+ path,
70
+ kind: oversized ? "oversized" : "file",
71
+ size: at.size,
72
+ });
73
+ const text = oversized ? undefined : fs.read(posix.join(dir, path));
74
+ if (text !== undefined)
75
+ texts.set(path, text);
76
+ };
77
+ for (const member of ["", ...(options.members ?? [])]) {
78
+ if (outside(member)) {
79
+ findings.push({
80
+ ruleId: "repo/lifecycle-script",
81
+ severity: "warn",
82
+ file: "package.json",
83
+ message: `the install also runs the install scripts of ${member}, a folder outside the project croak cannot read`,
84
+ });
85
+ continue;
86
+ }
87
+ const manifestPath = posix.join(member, "package.json");
88
+ const manifest = fs.read(posix.join(dir, manifestPath));
89
+ if (manifest === undefined)
90
+ continue;
91
+ let scripts = {};
92
+ try {
93
+ const data = JSON.parse(stripBom(manifest));
94
+ if (isRecord(data) && isRecord(data.scripts))
95
+ scripts = data.scripts;
96
+ }
97
+ catch {
98
+ // The detector reports an unreadable package.json itself.
99
+ }
100
+ // Every local file any script runs (install scripts call others via npm run).
101
+ const files = new Set(["package.json", "binding.gyp"]);
102
+ for (const command of Object.values(scripts))
103
+ if (typeof command === "string")
104
+ for (const file of analyzeShell(command).localFiles) {
105
+ const path = posix.normalize(file);
106
+ if (!outside(path))
107
+ files.add(path);
108
+ }
109
+ for (const file of files)
110
+ add(posix.join(member, file));
111
+ }
112
+ const snapshot = { entries, read: (path) => texts.get(path) };
113
+ return [
114
+ ...findings,
115
+ ...policies[options.pnpm === true ? "pnpm" : "npm"](snapshot),
116
+ ];
117
+ }
118
+ 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
+ export function pnpmfileFindings(dir, fs) {
127
+ const findings = [];
128
+ let settings = {};
129
+ const workspace = fs.read(posix.join(dir, "pnpm-workspace.yaml"));
130
+ if (workspace !== undefined) {
131
+ try {
132
+ const [doc] = parseYamlDocuments(workspace);
133
+ if (isRecord(doc))
134
+ settings = doc;
135
+ }
136
+ catch (error) {
137
+ if (!(error instanceof YamlError))
138
+ throw error;
139
+ findings.push({
140
+ ruleId: PNPM_RULE,
141
+ severity: "warn",
142
+ file: "pnpm-workspace.yaml",
143
+ message: "pnpm-workspace.yaml cannot be read, so croak cannot tell what pnpm loads on install",
144
+ });
145
+ }
146
+ }
147
+ // One file or a list of them (M6 review).
148
+ const listed = Array.isArray(settings.pnpmfile)
149
+ ? settings.pnpmfile
150
+ : settings.pnpmfile === undefined
151
+ ? []
152
+ : [settings.pnpmfile];
153
+ const named = listed.map((file) => posix.normalize(String(file)));
154
+ for (const file of [...new Set([...PNPMFILES, ...named])]) {
155
+ if (file === ".." || file.startsWith("../") || file.startsWith("/")) {
156
+ findings.push({
157
+ ruleId: PNPM_RULE,
158
+ severity: "warn",
159
+ file: "pnpm-workspace.yaml",
160
+ message: `pnpm runs ${file}, a pnpmfile outside the project, on every install`,
161
+ });
162
+ continue;
163
+ }
164
+ const at = fs.kind(posix.join(dir, file));
165
+ if (at === undefined)
166
+ continue;
167
+ const text = at.kind === "file" && at.size <= MAX_FILE_BYTES
168
+ ? fs.read(posix.join(dir, file))
169
+ : undefined;
170
+ if (text === undefined) {
171
+ findings.push({
172
+ ruleId: PNPM_RULE,
173
+ severity: "warn",
174
+ file,
175
+ message: `pnpm runs ${file} on every install, and croak could not read it`,
176
+ });
177
+ continue;
178
+ }
179
+ const hits = codeSignals(text);
180
+ const signals = new Set(hits.map((h) => h.signal));
181
+ findings.push({
182
+ ruleId: PNPM_RULE,
183
+ severity: isSuspicious(signals) ? "block" : "warn",
184
+ file,
185
+ message: `pnpm runs ${file} on every install${hits.length > 0 ? `: ${describeSignals(hits, file)}` : ""}`,
186
+ });
187
+ }
188
+ // Patches change a dependency's files after croak checked its tarball, its
189
+ // package.json and install scripts included (M6 review).
190
+ let manifestSettings;
191
+ const manifest = fs.read(posix.join(dir, "package.json"));
192
+ if (manifest !== undefined)
193
+ try {
194
+ const data = JSON.parse(stripBom(manifest));
195
+ if (isRecord(data))
196
+ manifestSettings = data.pnpm;
197
+ }
198
+ catch {
199
+ // The install itself fails on it; the plan reports the lockfile.
200
+ }
201
+ for (const [where, from] of [
202
+ ["pnpm-workspace.yaml", settings],
203
+ ["package.json", manifestSettings],
204
+ ]) {
205
+ const patched = isRecord(from) ? from.patchedDependencies : undefined;
206
+ if (isRecord(patched) && Object.keys(patched).length > 0)
207
+ findings.push({
208
+ ruleId: PNPM_RULE,
209
+ severity: "warn",
210
+ file: where,
211
+ message: `pnpm patches dependencies after download, past what croak checked: ${Object.keys(patched).slice(0, 5).join(", ")}`,
212
+ });
213
+ }
214
+ if (isRecord(settings.configDependencies) &&
215
+ Object.keys(settings.configDependencies).length > 0)
216
+ findings.push({
217
+ ruleId: PNPM_RULE,
218
+ severity: "warn",
219
+ file: "pnpm-workspace.yaml",
220
+ message: `pnpm installs config dependencies first and loads their pnpmfiles: ${Object.keys(settings.configDependencies).slice(0, 5).join(", ")}`,
221
+ });
222
+ return findings;
223
+ }
@@ -0,0 +1,77 @@
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
+ function workflowOf(identity) {
6
+ const uri = identity.buildConfigUri?.replace(/@[^@]*$/, "");
7
+ const repo = `${identity.sourceRepositoryUri ?? ""}/`;
8
+ return uri?.startsWith(repo) === true ? uri.slice(repo.length) : uri;
9
+ }
10
+ const repoIdOf = (identity) => identity.sourceRepositoryId === undefined
11
+ ? undefined
12
+ : `${identity.issuer ?? ""}#${identity.sourceRepositoryId}`;
13
+ /** How this build differs from the previous version's, if it does. */
14
+ function buildChanges(now, before, version) {
15
+ 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
+ const nowId = repoIdOf(now);
20
+ const beforeId = repoIdOf(before);
21
+ const otherRepo = nowId !== undefined && beforeId !== undefined
22
+ ? nowId !== beforeId
23
+ : now.sourceRepositoryUri !== before.sourceRepositoryUri;
24
+ if (otherRepo)
25
+ changes.push(`was built from ${now.sourceRepositoryUri ?? "an unnamed repository"}, not from ${before.sourceRepositoryUri ?? "the repository"}, which built ${version}`);
26
+ else if (workflowOf(now) !== workflowOf(before))
27
+ changes.push(`was built by the workflow ${workflowOf(now) ?? "(unnamed)"}, not by ${workflowOf(before) ?? "the workflow"}, which built ${version}`);
28
+ return changes;
29
+ }
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
+ export function provenanceBaseline(facts) {
37
+ return facts.previous?.hasProvenance === true
38
+ ? facts.previous
39
+ : facts.earlier.find((v) => v.hasProvenance);
40
+ }
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
+ export function provenanceFindings(change, current, baseline, installCommands) {
49
+ const file = `${change.entry.name}@${change.entry.version ?? "?"}`;
50
+ const finding = (severity, message) => ({
51
+ ruleId: "install/provenance",
52
+ severity,
53
+ file,
54
+ message,
55
+ });
56
+ if (current === "missing")
57
+ return [
58
+ finding("warn", "is listed with provenance, but the registry served no provenance attestation to check"),
59
+ ];
60
+ if (current.status === "invalid")
61
+ return [
62
+ finding("block", `claims provenance that does not verify (${current.reason}): the attestation was forged or altered`),
63
+ ];
64
+ if (current.status === "unverifiable")
65
+ return [
66
+ finding("warn", `has provenance croak cannot check: ${current.reason}`),
67
+ ];
68
+ const before = baseline?.provenance;
69
+ if (baseline === undefined || before === "missing" || before === undefined)
70
+ return [];
71
+ if (before.status !== "verified")
72
+ return [];
73
+ const newScript = installCommands !== "" && installCommands !== baseline.installCommands;
74
+ return buildChanges(current.identity, before.identity, baseline.version).map((what) => newScript
75
+ ? finding("block", `${what}, and adds or changes an install script: the pattern of a hijacked release`)
76
+ : finding("warn", what));
77
+ }