@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.
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/dist/cli.js +373 -0
- package/dist/crypto/der.js +136 -0
- package/dist/crypto/fields.js +60 -0
- package/dist/crypto/registry-signature.js +88 -0
- package/dist/crypto/rekor.js +70 -0
- package/dist/crypto/sigstore.js +121 -0
- package/dist/detectors/git-config.js +137 -0
- package/dist/detectors/hidden-code.js +58 -0
- package/dist/detectors/ide-autorun.js +136 -0
- package/dist/detectors/index.js +20 -0
- package/dist/detectors/lifecycle-script.js +337 -0
- package/dist/detectors/node-modules-present.js +32 -0
- package/dist/detectors/obfuscation.js +124 -0
- package/dist/detectors/unscanned.js +41 -0
- package/dist/hook/hook.js +391 -0
- package/dist/hook/install-commands.js +927 -0
- package/dist/install/content-cache.js +94 -0
- package/dist/install/content.js +42 -0
- package/dist/install/detectors.js +79 -0
- package/dist/install/installed.js +99 -0
- package/dist/install/lockfile.js +72 -0
- package/dist/install/metadata.js +159 -0
- package/dist/install/plan.js +31 -0
- package/dist/install/pnpm-builds.js +111 -0
- package/dist/install/pnpm-lockfile.js +102 -0
- package/dist/install/popular.js +177 -0
- package/dist/install/project.js +223 -0
- package/dist/install/provenance.js +77 -0
- package/dist/install/registry.js +274 -0
- package/dist/install/report.js +26 -0
- package/dist/install/sanitize.js +88 -0
- package/dist/install/scan.js +302 -0
- package/dist/install/signature.js +62 -0
- package/dist/install/tar.js +178 -0
- package/dist/install/workspace.js +31 -0
- package/dist/install/yaml.js +282 -0
- package/dist/llm/client.js +22 -0
- package/dist/llm/cost.js +26 -0
- package/dist/llm/diff.js +114 -0
- package/dist/llm/escalate.js +14 -0
- package/dist/llm/plan-step.js +91 -0
- package/dist/llm/select.js +21 -0
- package/dist/llm/triage.js +111 -0
- package/dist/repo-report.js +95 -0
- package/dist/repo-snapshot.js +157 -0
- package/dist/scan.js +48 -0
- package/dist/signals/code.js +136 -0
- package/dist/signals/git-config.js +58 -0
- package/dist/signals/hidden.js +77 -0
- package/dist/signals/jsonc.js +100 -0
- package/dist/signals/obfuscation.js +98 -0
- package/dist/signals/shell.js +717 -0
- package/dist/signals/text.js +2 -0
- package/dist/ui.js +12 -0
- package/dist/verdict.js +20 -0
- package/package.json +71 -4
- package/scripts/croak-hook.sh +59 -0
- 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
|
+
}
|