@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,94 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ /** Only sha512 is strong enough to stand in for the tarball's content. */
5
+ const cacheable = (integrity) => integrity !== undefined &&
6
+ /^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity.trim());
7
+ const SEVERITIES = new Set(["info", "warn", "block"]);
8
+ function isFinding(value) {
9
+ if (typeof value !== "object" || value === null)
10
+ return false;
11
+ const f = value;
12
+ return (typeof f.ruleId === "string" &&
13
+ typeof f.file === "string" &&
14
+ typeof f.message === "string" &&
15
+ typeof f.severity === "string" &&
16
+ SEVERITIES.has(f.severity) &&
17
+ (f.line === undefined || typeof f.line === "number"));
18
+ }
19
+ /** Errors from the filesystem (not bugs): the cache degrades to a miss. */
20
+ const isFsError = (error) => error instanceof Error &&
21
+ typeof error.code === "string";
22
+ export function fileContentCache(dir, fingerprint) {
23
+ // One file per tarball and label: content and provenance findings for the
24
+ // same tarball do not overwrite each other.
25
+ const path = (integrity, label) => join(dir, `${createHash("sha256").update(`${fingerprint}\n${integrity.trim()}\n${label}`).digest("hex")}.json`);
26
+ return {
27
+ get(integrity, label) {
28
+ if (!cacheable(integrity))
29
+ return undefined;
30
+ let data;
31
+ try {
32
+ data = JSON.parse(readFileSync(path(integrity, label), "utf8"));
33
+ }
34
+ catch (error) {
35
+ if (error instanceof SyntaxError || isFsError(error))
36
+ return undefined;
37
+ throw error;
38
+ }
39
+ if (typeof data !== "object" || data === null)
40
+ return undefined;
41
+ const entry = data;
42
+ // The same tarball under another name or version is not reused.
43
+ if (entry.label !== label ||
44
+ !Array.isArray(entry.findings) ||
45
+ !entry.findings.every(isFinding))
46
+ return undefined;
47
+ return entry.findings;
48
+ },
49
+ set(integrity, label, findings) {
50
+ if (!cacheable(integrity))
51
+ return;
52
+ const file = path(integrity, label);
53
+ const tmp = `${file}.${process.pid}.tmp`;
54
+ try {
55
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
56
+ // Write then rename, so a concurrent reader never sees half a file.
57
+ writeFileSync(tmp, JSON.stringify({ label, findings }), {
58
+ mode: 0o600,
59
+ });
60
+ renameSync(tmp, file);
61
+ }
62
+ catch (error) {
63
+ if (!isFsError(error))
64
+ throw error;
65
+ try {
66
+ rmSync(tmp, { force: true });
67
+ }
68
+ catch (cleanup) {
69
+ // Best effort: a stray .tmp file is never read back.
70
+ if (!isFsError(cleanup))
71
+ throw cleanup;
72
+ }
73
+ }
74
+ },
75
+ };
76
+ }
77
+ /** A hash of croak's own code (the src/ or dist/ tree this module runs from). */
78
+ export function codeFingerprint(root) {
79
+ const hash = createHash("sha256");
80
+ const walk = (dir) => {
81
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
82
+ const full = join(dir, entry.name);
83
+ if (entry.isDirectory())
84
+ walk(full);
85
+ else if (/\.[jt]s$/.test(entry.name) &&
86
+ !/\.test\.[jt]s$/.test(entry.name)) {
87
+ hash.update(`${full.slice(root.length)}\n`);
88
+ hash.update(readFileSync(full));
89
+ }
90
+ }
91
+ };
92
+ walk(root);
93
+ return hash.digest("hex");
94
+ }
@@ -0,0 +1,42 @@
1
+ import { hiddenCodeWith } from "../detectors/hidden-code.js";
2
+ import { DEPENDENCY_INSTALL_SCRIPTS, lifecycleScriptWith, } from "../detectors/lifecycle-script.js";
3
+ import { obfuscationWith } from "../detectors/obfuscation.js";
4
+ import { unscannedWith } from "../detectors/unscanned.js";
5
+ import { isSuspiciousInDependency } from "../signals/code.js";
6
+ /**
7
+ * The repo detectors that make sense for a package's own files. Not
8
+ * ide-autorun or git-config (they do nothing inside node_modules) and not
9
+ * node-modules-present (bundled dependencies are normal in a tarball).
10
+ */
11
+ const DEPENDENCY_DETECTORS = [
12
+ unscannedWith({ jsonIsData: true, downgradeInstalled: false }),
13
+ lifecycleScriptWith({
14
+ suspicious: isSuspiciousInDependency,
15
+ scripts: DEPENDENCY_INSTALL_SCRIPTS,
16
+ manifests: "package",
17
+ }),
18
+ // Bundled dependencies (node_modules inside the tarball) are code npm
19
+ // installs and runs: judged like the package itself, never downgraded.
20
+ hiddenCodeWith({ downgradeInstalled: false }),
21
+ obfuscationWith({ downgradeInstalled: false }),
22
+ ];
23
+ /** Findings for one package's contents, with paths prefixed by `name@version/`. */
24
+ export function contentFindings(label, snapshot) {
25
+ const findings = DEPENDENCY_DETECTORS.flatMap((detect) => detect(snapshot)).map((finding) => ({
26
+ ...finding,
27
+ file: `${label}/${finding.file}`,
28
+ }));
29
+ // Context, not a warning: prebuilt binaries are how esbuild, swc, sharp
30
+ // and TypeScript 7 ship. What runs them at install time is checked above.
31
+ const natives = snapshot.entries.filter((e) => e.path.endsWith(".node"));
32
+ const first = natives[0];
33
+ if (first !== undefined) {
34
+ findings.push({
35
+ ruleId: "install/native-binary",
36
+ severity: "info",
37
+ file: `${label}/${first.path}`,
38
+ message: `ships ${natives.length} prebuilt native binar${natives.length === 1 ? "y" : "ies"} (.node) that croak cannot inspect`,
39
+ });
40
+ }
41
+ return findings;
42
+ }
@@ -0,0 +1,79 @@
1
+ import { REGISTRY, registryTarball } from "./registry.js";
2
+ const label = (change) => `${change.entry.name}@${change.entry.version ?? "?"}`;
3
+ const what = (change) => change.kind === "changed"
4
+ ? `updates ${change.entry.name} ${change.previousVersion} → ${change.entry.version}`
5
+ : "is new";
6
+ /** npm records hasInstallScript for packages with preinstall/install/postinstall (or binding.gyp). */
7
+ export const installScripts = (changes) => changes
8
+ .filter((c) => c.entry.hasInstallScript)
9
+ .map((c) => ({
10
+ ruleId: "install/install-script",
11
+ severity: "warn",
12
+ file: label(c),
13
+ message: `${what(c)} and runs code at install time (preinstall/install/postinstall or a native build)`,
14
+ }));
15
+ /** Why a package is not a registry download, or undefined when it is one (registryTarball). */
16
+ function sourceProblem(resolved, name, version) {
17
+ const tarball = registryTarball(resolved);
18
+ if (tarball !== undefined) {
19
+ // The lockfile's name decides which registry facts croak reads: the URL
20
+ // must be that package's own tarball, not another package's.
21
+ const base = name.slice(name.lastIndexOf("/") + 1);
22
+ return decodeURIComponent(tarball.pathname) ===
23
+ `/${name}/-/${base}-${version ?? ""}.tgz`
24
+ ? undefined
25
+ : `is named ${name}@${version ?? "?"} but its tarball URL is for another package: ${tarball.pathname}`;
26
+ }
27
+ if (resolved === undefined)
28
+ return "has no resolved source in the lockfile";
29
+ if (/^(?:git\+|git:|github:|gitlab:|bitbucket:)/.test(resolved))
30
+ return "comes from git, not the npm registry";
31
+ if (resolved.startsWith("file:"))
32
+ return "comes from a local path, not the npm registry";
33
+ if (!URL.canParse(resolved))
34
+ return `has an unrecognized source: ${resolved}`;
35
+ const url = new URL(resolved);
36
+ if (url.protocol !== "https:")
37
+ return `is downloaded over ${url.protocol.replace(":", "")}, not https`;
38
+ return `comes from ${url.host}${url.username === "" ? "" : " with credentials in the URL"}, not ${REGISTRY}`;
39
+ }
40
+ /** Packages that do not come from the public npm registry over https. */
41
+ export const source = (changes) => changes.flatMap((c) => {
42
+ if (c.entry.link) {
43
+ return [
44
+ {
45
+ ruleId: "install/source",
46
+ severity: "info",
47
+ file: label(c),
48
+ message: "is a workspace link, not a download",
49
+ },
50
+ ];
51
+ }
52
+ const problem = sourceProblem(c.entry.resolved, c.entry.name, c.entry.version);
53
+ return problem === undefined
54
+ ? []
55
+ : [
56
+ {
57
+ ruleId: "install/source",
58
+ severity: "warn",
59
+ file: label(c),
60
+ message: `${what(c)} and ${problem}`,
61
+ },
62
+ ];
63
+ });
64
+ /** A downloaded package without an integrity hash is not verified by npm. */
65
+ export const integrity = (changes) => changes
66
+ .filter((c) => !c.entry.link &&
67
+ c.entry.resolved !== undefined &&
68
+ c.entry.integrity === undefined)
69
+ .map((c) => ({
70
+ ruleId: "install/integrity",
71
+ severity: "warn",
72
+ file: label(c),
73
+ message: `${what(c)} and has no integrity hash, so the package manager does not verify the download`,
74
+ }));
75
+ export const installDetectors = [
76
+ installScripts,
77
+ source,
78
+ integrity,
79
+ ];
@@ -0,0 +1,99 @@
1
+ import { posix } from "node:path";
2
+ import { stripBom } from "../signals/text.js";
3
+ /**
4
+ * What is really installed in a project, as a lockfile: the entries of
5
+ * node_modules/.package-lock.json whose folder holds a package.json with the
6
+ * same name and version (M4 review, D34). npm ignores hidden-lockfile entries
7
+ * whose folders are missing, so a committed or stale hidden lockfile must not
8
+ * make croak treat the tree as installed and skip it.
9
+ */
10
+ function isRecord(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ const nameFromPath = (path) => path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
14
+ /** The verified lockfile text, or undefined when nothing is installed. */
15
+ export function installedLockfile(project, read) {
16
+ const text = read(posix.join(project, "node_modules", ".package-lock.json"));
17
+ if (text === undefined)
18
+ return undefined;
19
+ let data;
20
+ try {
21
+ data = JSON.parse(stripBom(text));
22
+ }
23
+ catch {
24
+ return undefined; // npm rebuilds an unreadable hidden lockfile from the tree
25
+ }
26
+ if (!isRecord(data) || !isRecord(data.packages))
27
+ return undefined;
28
+ const kept = {};
29
+ for (const [path, entry] of Object.entries(data.packages)) {
30
+ if (!isRecord(entry) || path === "" || !path.startsWith("node_modules/"))
31
+ continue;
32
+ let manifest;
33
+ try {
34
+ manifest = JSON.parse(stripBom(read(posix.join(project, path, "package.json")) ?? ""));
35
+ }
36
+ catch {
37
+ continue; // no folder, or no readable package.json: not installed
38
+ }
39
+ const name = typeof entry.name === "string" ? entry.name : nameFromPath(path);
40
+ if (isRecord(manifest) &&
41
+ manifest.name === name &&
42
+ manifest.version === entry.version)
43
+ kept[path] = entry;
44
+ }
45
+ return JSON.stringify({
46
+ ...data,
47
+ packages: { "": data.packages[""] ?? {}, ...kept },
48
+ });
49
+ }
50
+ /** "@scope/name" → "@scope+name", as pnpm names its virtual store folders. */
51
+ const storeName = (name) => name.replace("/", "+");
52
+ /**
53
+ * The same for pnpm (M6, D38): the entries of node_modules/.pnpm/lock.yaml
54
+ * whose virtual-store folder (name@version, or name@version_peers…) holds a
55
+ * package.json with that name and version, as an npm v3 lockfile so the plan
56
+ * diff is shared. A folder croak cannot find (pnpm shortens very long names)
57
+ * counts as not installed, so it is checked.
58
+ */
59
+ export function installedPnpmLockfile(project, read, list, parse) {
60
+ const text = read(posix.join(project, "node_modules", ".pnpm", "lock.yaml"));
61
+ if (text === undefined)
62
+ return undefined;
63
+ let entries;
64
+ try {
65
+ entries = parse(text);
66
+ }
67
+ catch {
68
+ return undefined; // an unreadable hidden lockfile means nothing counts as installed
69
+ }
70
+ const store = posix.join(project, "node_modules", ".pnpm");
71
+ const folders = list(store);
72
+ const packages = { "": {} };
73
+ for (const entry of entries) {
74
+ if (entry.link || entry.version === undefined)
75
+ continue;
76
+ const base = `${storeName(entry.name)}@${entry.version}`;
77
+ const installed = folders
78
+ .filter((f) => f === base || f.startsWith(`${base}_`) || f.startsWith(`${base}(`))
79
+ .some((folder) => {
80
+ try {
81
+ const manifest = JSON.parse(stripBom(read(posix.join(store, folder, "node_modules", entry.name, "package.json")) ?? ""));
82
+ return (isRecord(manifest) &&
83
+ manifest.name === entry.name &&
84
+ manifest.version === entry.version);
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ });
90
+ if (installed)
91
+ packages[entry.path] = {
92
+ name: entry.name,
93
+ version: entry.version,
94
+ resolved: entry.resolved,
95
+ integrity: entry.integrity,
96
+ };
97
+ }
98
+ return JSON.stringify({ lockfileVersion: 3, packages });
99
+ }
@@ -0,0 +1,72 @@
1
+ import { stripBom } from "../signals/text.js";
2
+ export class LockfileError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "LockfileError";
6
+ }
7
+ }
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ const asString = (value) => typeof value === "string" ? value : undefined;
12
+ const asStrings = (value) => Array.isArray(value)
13
+ ? value.filter((v) => typeof v === "string")
14
+ : [];
15
+ /** npm's rule: refused by `!value`; if any value is listed without `!`, it must be one of them. */
16
+ function accepts(list, value) {
17
+ if (list.includes(`!${value}`))
18
+ return false;
19
+ const allowed = list.filter((v) => !v.startsWith("!"));
20
+ return allowed.length === 0 || allowed.includes(value);
21
+ }
22
+ /** Whether npm would install this entry on the platform (platform packages for other systems are skipped). */
23
+ export function installsOn(entry, platform) {
24
+ return accepts(entry.os, platform.os) && accepts(entry.cpu, platform.cpu);
25
+ }
26
+ /** `node_modules/a/node_modules/@s/b` → `@s/b`. */
27
+ function nameFromPath(path) {
28
+ const index = path.lastIndexOf("node_modules/");
29
+ return index === -1 ? path : path.slice(index + "node_modules/".length);
30
+ }
31
+ /** Parses an npm lockfile (v2 or v3). Lockfile v1 has no `packages` map and is rejected. */
32
+ export function parseLockfile(text) {
33
+ let data;
34
+ try {
35
+ data = JSON.parse(stripBom(text));
36
+ }
37
+ catch (error) {
38
+ throw new LockfileError(`not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ if (!isRecord(data) || !isRecord(data.packages)) {
41
+ throw new LockfileError("no packages map: lockfile v1 is not supported, regenerate it with npm 7 or later");
42
+ }
43
+ return Object.entries(data.packages).flatMap(([path, raw]) => {
44
+ if (path === "")
45
+ return [];
46
+ if (!isRecord(raw))
47
+ throw new LockfileError(`entry ${path} is not an object`);
48
+ // Without a version croak cannot look the package up; npm always writes one.
49
+ if (raw.link !== true && typeof raw.version !== "string")
50
+ throw new LockfileError(`entry ${path} has no version`);
51
+ return [
52
+ {
53
+ path,
54
+ name: asString(raw.name) ?? nameFromPath(path),
55
+ version: asString(raw.version),
56
+ resolved: asString(raw.resolved),
57
+ integrity: asString(raw.integrity),
58
+ 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
+ link: raw.link === true ||
62
+ (!path.startsWith("node_modules/") &&
63
+ !path.includes("/node_modules/") &&
64
+ raw.resolved === undefined),
65
+ dev: raw.dev === true,
66
+ optional: raw.optional === true,
67
+ os: asStrings(raw.os),
68
+ cpu: asStrings(raw.cpu),
69
+ },
70
+ ];
71
+ });
72
+ }
@@ -0,0 +1,159 @@
1
+ import { POPULAR_PACKAGES } from "./popular.js";
2
+ const POPULAR = new Set(POPULAR_PACKAGES);
3
+ const RECENT_DAYS = 7;
4
+ const NEW_PACKAGE_DAYS = 30;
5
+ const DAY_MS = 24 * 60 * 60 * 1000;
6
+ const normalize = (name) => name.toLowerCase().replace(/[-_.]/g, "");
7
+ /** Optimal string alignment distance (Levenshtein plus adjacent transpositions), capped at `cap`. */
8
+ function distance(a, b, cap = 2) {
9
+ if (Math.abs(a.length - b.length) >= cap)
10
+ return cap;
11
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [
12
+ i,
13
+ ...Array(b.length).fill(0),
14
+ ]);
15
+ for (let j = 0; j <= b.length; j++)
16
+ d[0][j] = j;
17
+ for (let i = 1; i <= a.length; i++) {
18
+ for (let j = 1; j <= b.length; j++) {
19
+ const row = d[i];
20
+ const prev = d[i - 1];
21
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
22
+ let best = Math.min((prev[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
23
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
24
+ best = Math.min(best, (d[i - 2][j - 2] ?? 0) + 1);
25
+ }
26
+ row[j] = best;
27
+ }
28
+ }
29
+ return Math.min(d[a.length][b.length] ?? cap, cap);
30
+ }
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
+ export function suggestNames(spec) {
38
+ const at = spec.indexOf("@", 1);
39
+ const name = (at === -1 ? spec : spec.slice(0, at)).toLowerCase();
40
+ if (name.startsWith("@") || name.length < 4 || POPULAR.has(name))
41
+ return [];
42
+ return POPULAR_PACKAGES.map((popular) => ({
43
+ popular,
44
+ d: normalize(popular) === normalize(name) ? 0 : distance(popular, name, 3),
45
+ }))
46
+ .filter(({ d }) => d <= 2)
47
+ .sort((x, y) => x.d - y.d)
48
+ .slice(0, 3)
49
+ .map(({ popular }) => popular);
50
+ }
51
+ /** The popular package this unscoped name is one keystroke (or one separator) away from. */
52
+ export function nearPopularName(name) {
53
+ if (name.startsWith("@") || POPULAR.has(name) || name.length < 5)
54
+ return undefined;
55
+ return POPULAR_PACKAGES.find((popular) => popular.length >= 5 &&
56
+ (normalize(popular) === normalize(name) || distance(popular, name) === 1));
57
+ }
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
+ export function metadataFindings(change, facts, now = new Date()) {
66
+ const { entry } = change;
67
+ const file = `${entry.name}@${entry.version ?? "?"}`;
68
+ const finding = (ruleId, severity, message) => ({ ruleId, severity, file, message });
69
+ if (facts === "not-found")
70
+ return [
71
+ finding("install/registry", "warn", "is not on the npm registry (unpublished or never published)"),
72
+ ];
73
+ if (facts === undefined)
74
+ return [
75
+ finding("install/registry", "warn", "is a version the npm registry does not list"),
76
+ ];
77
+ const findings = [];
78
+ const isNewPackage = facts.created !== undefined &&
79
+ now.getTime() - facts.created.getTime() < NEW_PACKAGE_DAYS * DAY_MS;
80
+ const recent = facts.earlier.slice(0, 5);
81
+ 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
+ const newPublisher = !facts.hasProvenance &&
85
+ facts.publisher !== undefined &&
86
+ facts.earlier.length > 0 &&
87
+ !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
+ const { previous } = facts;
92
+ const sameScript = previous !== undefined &&
93
+ facts.installCommands !== "" &&
94
+ 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
+ const runsAtInstall = entry.hasInstallScript || facts.installScripts.length > 0;
98
+ const hijack = runsAtInstall && (provenanceDropped || (newPublisher && !sameScript));
99
+ const recentRelease = facts.ageDays !== undefined && facts.ageDays < RECENT_DAYS;
100
+ const pattern = hijack
101
+ ? "; with an install script, this is the pattern of a hijacked maintainer account"
102
+ : "";
103
+ if (recentRelease) {
104
+ findings.push(finding("install/recent-release", "warn", `was published ${facts.ageDays} day(s) ago; new versions of popular packages are where hijacks land first`));
105
+ }
106
+ if (provenanceDropped) {
107
+ findings.push(finding("install/provenance", hijack ? "block" : "warn", `has no provenance, although earlier versions were published with it${pattern}`));
108
+ }
109
+ 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}` : ""}`));
114
+ }
115
+ const lookalike = nearPopularName(entry.name);
116
+ if (lookalike !== undefined) {
117
+ findings.push(finding("install/name", isNewPackage ? "block" : "warn", `looks like the popular package ${lookalike}${isNewPackage ? ", and the package is brand new: a typo-squat or a hallucinated name" : "; check the name"} (did you mean \`${lookalike}\`?)`));
118
+ }
119
+ if (previous !== undefined &&
120
+ facts.installScripts.length > 0 &&
121
+ previous.installScripts.length === 0) {
122
+ const risky = recentRelease || provenanceDropped || newPublisher;
123
+ findings.push(finding("install/new-install-script", risky ? "block" : "warn", `adds an install script (${facts.installScripts.join(", ")}) that ${previous.version} did not have${risky ? ", in a release that also shows a registry warning: the pattern of a hijacked release" : ""}`));
124
+ }
125
+ if (isNewPackage)
126
+ findings.push(finding("install/new-package", "warn", `is a brand-new package (created less than ${NEW_PACKAGE_DAYS} days ago)`));
127
+ return findings;
128
+ }
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
+ export function addedDependencyFindings(change, facts, createdByName, now = new Date()) {
135
+ const { previous } = facts;
136
+ if (previous === undefined)
137
+ return [];
138
+ const added = facts.dependencies.filter((name) => !previous.dependencies.includes(name));
139
+ const file = `${change.entry.name}@${change.entry.version ?? "?"}`;
140
+ return added.map((name) => {
141
+ const created = createdByName.get(name);
142
+ const days = created === undefined
143
+ ? undefined
144
+ : Math.floor((now.getTime() - created.getTime()) / DAY_MS);
145
+ return days !== undefined && days < NEW_PACKAGE_DAYS
146
+ ? {
147
+ ruleId: "install/new-dependency",
148
+ severity: "block",
149
+ file,
150
+ message: `adds dependency ${name}, a package created ${days} day(s) ago, that ${previous.version} did not have: how hijacked releases smuggle in a payload`,
151
+ }
152
+ : {
153
+ ruleId: "install/new-dependency",
154
+ severity: "info",
155
+ file,
156
+ message: `adds dependency ${name} (not in ${previous.version})`,
157
+ };
158
+ });
159
+ }
@@ -0,0 +1,31 @@
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
+ 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
+ export function diffLockfiles(before, after) {
13
+ const had = new Set((before ?? []).map(key));
14
+ const previous = new Map();
15
+ for (const entry of before ?? [])
16
+ if (entry.version !== undefined)
17
+ previous.set(entry.name, entry.version);
18
+ const seen = new Set();
19
+ const changes = [];
20
+ for (const entry of after) {
21
+ const id = key(entry);
22
+ if (had.has(id) || seen.has(id))
23
+ continue;
24
+ seen.add(id);
25
+ const old = previous.get(entry.name);
26
+ changes.push(old === undefined
27
+ ? { kind: "added", entry }
28
+ : { kind: "changed", entry, previousVersion: old });
29
+ }
30
+ return changes;
31
+ }