@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.
- package/dist/cli.js +1 -27
- package/dist/crypto/der.js +0 -13
- package/dist/crypto/fields.js +0 -11
- package/dist/crypto/registry-signature.js +0 -12
- package/dist/crypto/rekor.js +0 -13
- package/dist/crypto/sigstore.js +0 -9
- package/dist/detectors/git-config.js +0 -4
- package/dist/detectors/hidden-code.js +0 -7
- package/dist/detectors/ide-autorun.js +0 -9
- package/dist/detectors/index.js +0 -1
- package/dist/detectors/lifecycle-script.js +2 -41
- package/dist/detectors/node-modules-present.js +0 -7
- package/dist/detectors/obfuscation.js +0 -26
- package/dist/detectors/unscanned.js +0 -8
- package/dist/hook/hook.js +2 -45
- package/dist/hook/install-commands.js +1 -36
- package/dist/install/content-cache.js +0 -8
- package/dist/install/content.js +0 -10
- package/dist/install/detectors.js +0 -6
- package/dist/install/installed.js +3 -19
- package/dist/install/lockfile.js +0 -7
- package/dist/install/metadata.js +1 -31
- package/dist/install/plan.js +0 -10
- package/dist/install/pnpm-builds.js +0 -13
- package/dist/install/pnpm-lockfile.js +0 -14
- package/dist/install/popular.js +0 -5
- package/dist/install/project.js +0 -20
- package/dist/install/provenance.js +0 -21
- package/dist/install/registry.js +0 -33
- package/dist/install/sanitize.js +0 -14
- package/dist/install/scan.js +0 -31
- package/dist/install/signature.js +0 -7
- package/dist/install/tar.js +1 -8
- package/dist/install/workspace.js +0 -1
- package/dist/install/yaml.js +0 -26
- package/dist/llm/client.js +0 -8
- package/dist/llm/cost.js +0 -2
- package/dist/llm/diff.js +0 -14
- package/dist/llm/plan-step.js +0 -5
- package/dist/llm/select.js +0 -6
- package/dist/llm/triage.js +0 -10
- package/dist/repo-report.js +0 -11
- package/dist/repo-snapshot.js +0 -17
- package/dist/scan.js +0 -5
- package/dist/signals/code.js +0 -29
- package/dist/signals/git-config.js +0 -7
- package/dist/signals/hidden.js +0 -17
- package/dist/signals/jsonc.js +0 -10
- package/dist/signals/obfuscation.js +0 -11
- package/dist/signals/shell.js +0 -40
- package/dist/signals/text.js +0 -1
- package/dist/ui.js +0 -1
- package/dist/verdict.js +0 -1
- package/package.json +1 -1
package/dist/install/lockfile.js
CHANGED
|
@@ -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/") &&
|
package/dist/install/metadata.js
CHANGED
|
@@ -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)
|
package/dist/install/plan.js
CHANGED
|
@@ -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}`,
|
package/dist/install/popular.js
CHANGED
|
@@ -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",
|
package/dist/install/project.js
CHANGED
|
@@ -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) => ({
|
package/dist/install/registry.js
CHANGED
|
@@ -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)
|
package/dist/install/sanitize.js
CHANGED
|
@@ -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;
|
package/dist/install/scan.js
CHANGED
|
@@ -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;
|