@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,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
|
+
}
|