@prestyj/cli 5.32.0 → 5.32.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/README.md +3 -2
- package/assets/skills/task-list/SKILL.md +39 -0
- package/dist/app-sidecar/app-settings.js +11 -5
- package/dist/app-sidecar/app-settings.js.map +1 -1
- package/dist/app-sidecar/auth-routes.js +10 -3
- package/dist/app-sidecar/auth-routes.js.map +1 -1
- package/dist/app-sidecar/git-target.d.ts +15 -2
- package/dist/app-sidecar/git-target.js +24 -10
- package/dist/app-sidecar/git-target.js.map +1 -1
- package/dist/app-sidecar/goal-routes.js +8 -0
- package/dist/app-sidecar/goal-routes.js.map +1 -1
- package/dist/app-sidecar/history-routes.js +7 -0
- package/dist/app-sidecar/history-routes.js.map +1 -1
- package/dist/app-sidecar/history.d.ts +5 -0
- package/dist/app-sidecar/history.js.map +1 -1
- package/dist/app-sidecar/integration-routes.js +4 -0
- package/dist/app-sidecar/integration-routes.js.map +1 -1
- package/dist/app-sidecar/mcp-routes.js +1 -0
- package/dist/app-sidecar/mcp-routes.js.map +1 -1
- package/dist/app-sidecar/mcp-rows.d.ts +2 -0
- package/dist/app-sidecar/mcp-rows.js +4 -1
- package/dist/app-sidecar/mcp-rows.js.map +1 -1
- package/dist/app-sidecar/prompt-routes.js +67 -1
- package/dist/app-sidecar/prompt-routes.js.map +1 -1
- package/dist/app-sidecar/route-context.d.ts +2 -0
- package/dist/app-sidecar/run-control-routes.js +43 -3
- package/dist/app-sidecar/run-control-routes.js.map +1 -1
- package/dist/app-sidecar/session-routes.js +10 -0
- package/dist/app-sidecar/session-routes.js.map +1 -1
- package/dist/app-sidecar/task-routes.js +2 -0
- package/dist/app-sidecar/task-routes.js.map +1 -1
- package/dist/app-sidecar/trophy-manager.d.ts +15 -0
- package/dist/app-sidecar/trophy-manager.js +71 -0
- package/dist/app-sidecar/trophy-manager.js.map +1 -0
- package/dist/app-sidecar/workspace-routes.js +1 -0
- package/dist/app-sidecar/workspace-routes.js.map +1 -1
- package/dist/app-sidecar.js +157 -30
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli/auth.js +22 -1
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/mcp.js +6 -1
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli.js +13 -9
- package/dist/cli.js.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/core/action-threats.d.ts +68 -0
- package/dist/core/action-threats.js +894 -0
- package/dist/core/action-threats.js.map +1 -0
- package/dist/core/agent-notifications.d.ts +18 -0
- package/dist/core/agent-notifications.js +35 -2
- package/dist/core/agent-notifications.js.map +1 -1
- package/dist/core/agent-session/verification-tracker.d.ts +21 -0
- package/dist/core/agent-session/verification-tracker.js +39 -1
- package/dist/core/agent-session/verification-tracker.js.map +1 -1
- package/dist/core/agent-session.d.ts +166 -2
- package/dist/core/agent-session.js +478 -22
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/blocked-packages-file.d.ts +33 -0
- package/dist/core/blocked-packages-file.js +274 -0
- package/dist/core/blocked-packages-file.js.map +1 -0
- package/dist/core/compaction/policy.d.ts +2 -2
- package/dist/core/compaction/policy.js +86 -43
- package/dist/core/compaction/policy.js.map +1 -1
- package/dist/core/compaction/token-estimator.d.ts +4 -1
- package/dist/core/compaction/token-estimator.js +5 -2
- package/dist/core/compaction/token-estimator.js.map +1 -1
- package/dist/core/event-bus.d.ts +10 -0
- package/dist/core/event-bus.js +2 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/idle-wake.d.ts +62 -0
- package/dist/core/idle-wake.js +103 -0
- package/dist/core/idle-wake.js.map +1 -0
- package/dist/core/injection-detect.d.ts +2 -1
- package/dist/core/injection-detect.js +13 -4
- package/dist/core/injection-detect.js.map +1 -1
- package/dist/core/installed-threats.d.ts +23 -0
- package/dist/core/installed-threats.js +71 -0
- package/dist/core/installed-threats.js.map +1 -0
- package/dist/core/mcp/catalog-cache.d.ts +2 -0
- package/dist/core/mcp/catalog-cache.js +4 -0
- package/dist/core/mcp/catalog-cache.js.map +1 -1
- package/dist/core/mcp/client.d.ts +8 -0
- package/dist/core/mcp/client.js +37 -3
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/deferred-catalog.d.ts +12 -1
- package/dist/core/mcp/deferred-catalog.js +37 -10
- package/dist/core/mcp/deferred-catalog.js.map +1 -1
- package/dist/core/mcp/index.d.ts +2 -0
- package/dist/core/mcp/index.js +1 -0
- package/dist/core/mcp/index.js.map +1 -1
- package/dist/core/mcp/tool-guard.d.ts +58 -0
- package/dist/core/mcp/tool-guard.js +215 -0
- package/dist/core/mcp/tool-guard.js.map +1 -0
- package/dist/core/network-guard.d.ts +0 -20
- package/dist/core/network-guard.js +6 -0
- package/dist/core/network-guard.js.map +1 -1
- package/dist/core/package-cooldown.d.ts +71 -0
- package/dist/core/package-cooldown.js +301 -0
- package/dist/core/package-cooldown.js.map +1 -0
- package/dist/core/package-threats.d.ts +77 -3
- package/dist/core/package-threats.js +1250 -78
- package/dist/core/package-threats.js.map +1 -1
- package/dist/core/process-manager.d.ts +54 -12
- package/dist/core/process-manager.js +233 -56
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/prompt-commands.js +1 -0
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/session-manager.d.ts +3 -1
- package/dist/core/session-manager.js +2 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -0
- package/dist/core/settings-manager.js +4 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/shell-threats.d.ts +14 -1
- package/dist/core/shell-threats.js +73 -24
- package/dist/core/shell-threats.js.map +1 -1
- package/dist/core/skill-scan.d.ts +112 -0
- package/dist/core/skill-scan.js +781 -0
- package/dist/core/skill-scan.js.map +1 -0
- package/dist/core/skills.d.ts +33 -0
- package/dist/core/skills.js +87 -16
- package/dist/core/skills.js.map +1 -1
- package/dist/core/slash-commands.d.ts +5 -1
- package/dist/core/slash-commands.js +24 -9
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/steering.d.ts +7 -0
- package/dist/core/steering.js +13 -0
- package/dist/core/steering.js.map +1 -1
- package/dist/core/style-packs/index.js +1 -1
- package/dist/core/style-packs/index.js.map +1 -1
- package/dist/core/style-packs/packs.js +2 -2
- package/dist/core/subagent-manager.js +5 -4
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/threat-feed.d.ts +65 -0
- package/dist/core/threat-feed.js +329 -0
- package/dist/core/threat-feed.js.map +1 -0
- package/dist/core/trophies/catalog-more.d.ts +2 -0
- package/dist/core/trophies/catalog-more.js +857 -0
- package/dist/core/trophies/catalog-more.js.map +1 -0
- package/dist/core/trophies/catalog.d.ts +4 -0
- package/dist/core/trophies/catalog.js +649 -0
- package/dist/core/trophies/catalog.js.map +1 -0
- package/dist/core/trophies/engine.d.ts +70 -0
- package/dist/core/trophies/engine.js +351 -0
- package/dist/core/trophies/engine.js.map +1 -0
- package/dist/core/trophies/store.d.ts +17 -0
- package/dist/core/trophies/store.js +113 -0
- package/dist/core/trophies/store.js.map +1 -0
- package/dist/core/trophies/types.d.ts +119 -0
- package/dist/core/trophies/types.js +78 -0
- package/dist/core/trophies/types.js.map +1 -0
- package/dist/core/verification-evidence.d.ts +10 -6
- package/dist/core/verification-evidence.js +190 -11
- package/dist/core/verification-evidence.js.map +1 -1
- package/dist/goals-agent/goals-agent.js +3 -2
- package/dist/goals-agent/goals-agent.js.map +1 -1
- package/dist/interactive.js +16 -5
- package/dist/interactive.js.map +1 -1
- package/dist/modes/acp-mode.js +8 -1
- package/dist/modes/acp-mode.js.map +1 -1
- package/dist/modes/agent-home-mode.js +13 -1
- package/dist/modes/agent-home-mode.js.map +1 -1
- package/dist/modes/rpc-mode.d.ts +2 -1
- package/dist/modes/rpc-mode.js +15 -3
- package/dist/modes/rpc-mode.js.map +1 -1
- package/dist/modes/serve-mode.js +13 -1
- package/dist/modes/serve-mode.js.map +1 -1
- package/dist/system-prompt.d.ts +1 -0
- package/dist/system-prompt.js +12 -8
- package/dist/system-prompt.js.map +1 -1
- package/dist/test-support/slow-git.d.ts +1 -0
- package/dist/test-support/slow-git.js +21 -0
- package/dist/test-support/slow-git.js.map +1 -0
- package/dist/tools/background-watch-lint.d.ts +18 -0
- package/dist/tools/background-watch-lint.js +102 -0
- package/dist/tools/background-watch-lint.js.map +1 -0
- package/dist/tools/bash.d.ts +15 -4
- package/dist/tools/bash.js +72 -47
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/checklist.d.ts +3 -3
- package/dist/tools/compress.js +45 -0
- package/dist/tools/compress.js.map +1 -1
- package/dist/tools/edit.js +5 -0
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/generate-image.d.ts +29 -6
- package/dist/tools/generate-image.js +325 -151
- package/dist/tools/generate-image.js.map +1 -1
- package/dist/tools/grep.d.ts +14 -0
- package/dist/tools/grep.js +36 -2
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +7 -4
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/skill.d.ts +2 -2
- package/dist/tools/skill.js +8 -1
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent-shared.js +4 -1
- package/dist/tools/subagent-shared.js.map +1 -1
- package/dist/tools/task-output.js +11 -1
- package/dist/tools/task-output.js.map +1 -1
- package/dist/tools/write.js +8 -0
- package/dist/tools/write.js.map +1 -1
- package/dist/ui/components/ModelSelector.d.ts +10 -0
- package/dist/ui/components/ModelSelector.js +51 -5
- package/dist/ui/components/ModelSelector.js.map +1 -1
- package/dist/ui/components/ToolExecution.js +2 -2
- package/dist/ui/components/ToolExecution.js.map +1 -1
- package/dist/ui/mcp.d.ts +2 -0
- package/dist/ui/mcp.js.map +1 -1
- package/dist/ui/terminal-history.js +2 -1
- package/dist/ui/terminal-history.js.map +1 -1
- package/dist/ui/tool-line-summary.js +3 -1
- package/dist/ui/tool-line-summary.js.map +1 -1
- package/dist/utils/git.d.ts +12 -3
- package/dist/utils/git.js +21 -8
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/image.d.ts +14 -0
- package/dist/utils/image.js +23 -0
- package/dist/utils/image.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,54 +1,149 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Package-install
|
|
3
|
-
*
|
|
2
|
+
* Package-install guard. Blocks only on concrete signals; everything else
|
|
3
|
+
* passes silently. A catch is a block with one plain line for the agent.
|
|
4
|
+
*
|
|
5
|
+
* - Known malware: local threat feed (instant) + OSV querybatch with the exact
|
|
6
|
+
* version that would install (name-only queries flag every package that
|
|
7
|
+
* ever had one bad release, e.g. chalk/debug).
|
|
8
|
+
* - Lockfile installs (`npm ci`, `pnpm install`, `uv sync`, …): every locked
|
|
9
|
+
* name@version, cached by lockfile hash.
|
|
10
|
+
* - Release age when the manager has no native cooldown (P3).
|
|
11
|
+
* - Provenance downgrade: previous version had attestations, this one not (P4).
|
|
12
|
+
* - Typosquat: one edit from a popular name AND new / missing / barely used (P5).
|
|
13
|
+
*
|
|
14
|
+
* Network only for install commands, parallel, one ≤2.5 s deadline, fail-open.
|
|
15
|
+
* Clean answers are cached on disk (package-verdicts.json in the GG dir).
|
|
4
16
|
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import fsp from "node:fs/promises";
|
|
19
|
+
import { getAppPaths } from "@prestyj/core";
|
|
20
|
+
import path from "node:path";
|
|
5
21
|
import { log } from "./logger.js";
|
|
6
22
|
import { POPULAR_NPM_PACKAGES, POPULAR_PYPI_PACKAGES } from "./shell-threats-popular-packages.js";
|
|
23
|
+
import { SAFETY_BLOCK_NOTE } from "./action-threats.js";
|
|
24
|
+
import { compareVersions, emptyFeed, feedMatches, loadThreatFeed, writeJsonAtomic, } from "./threat-feed.js";
|
|
25
|
+
import { hasNativeCooldown, managerVersion, usesCustomNpmRegistry, usesCustomPypiIndex, } from "./package-cooldown.js";
|
|
7
26
|
const OSV_URL = "https://api.osv.dev/v1/querybatch";
|
|
8
|
-
|
|
27
|
+
/** Full advisories for one version (severity + fixed versions); only asked when flawed. */
|
|
28
|
+
const OSV_QUERY_URL = "https://api.osv.dev/v1/query";
|
|
29
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
30
|
+
const NPM_DOWNLOADS = "https://api.npmjs.org/downloads/point/last-week";
|
|
31
|
+
const PYPI = "https://pypi.org/pypi";
|
|
32
|
+
const TIMEOUT_MS = 2_500;
|
|
33
|
+
const OSV_BATCH = 1000;
|
|
34
|
+
const DAY_MS = 86_400_000;
|
|
35
|
+
const TYPOSQUAT_NEW_DAYS = 90;
|
|
36
|
+
const TYPOSQUAT_MIN_WEEKLY = 500;
|
|
9
37
|
const NPM_INSTALL = new Set(["add", "install", "i"]);
|
|
38
|
+
// Per-process caches; answers are stable within a session.
|
|
39
|
+
/**
|
|
40
|
+
* OSV answers by `eco:name@version`: MAL ids, whether it has any other
|
|
41
|
+
* (vulnerability) advisories, and when asked. Clean answers expire after a day.
|
|
42
|
+
*/
|
|
10
43
|
const osvCache = new Map();
|
|
11
|
-
/**
|
|
12
|
-
|
|
44
|
+
/** Serious-flaw verdicts by `eco:name@version` (detailed OSV query), with when asked. */
|
|
45
|
+
const vulnCache = new Map();
|
|
46
|
+
const metaCache = new Map();
|
|
47
|
+
const lockfileCache = new Map();
|
|
48
|
+
// ── Parsing ───────────────────────────────────────────────
|
|
49
|
+
/** Split `react@18`, `@a/b@1`, `requests==2.0`, `x[extra]>=1` into name + spec. */
|
|
50
|
+
function splitSpec(raw, ecosystem) {
|
|
13
51
|
if (ecosystem === "npm") {
|
|
14
|
-
const at =
|
|
15
|
-
const name = at === -1 ?
|
|
16
|
-
|
|
52
|
+
const at = raw.indexOf("@", 1);
|
|
53
|
+
const name = at === -1 ? raw : raw.slice(0, at);
|
|
54
|
+
const spec = at === -1 ? undefined : raw.slice(at + 1) || undefined;
|
|
55
|
+
if (spec?.startsWith("npm:") || spec?.includes(":") || spec?.includes("/"))
|
|
56
|
+
return null;
|
|
57
|
+
return /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(name)
|
|
58
|
+
? { name: name.toLowerCase(), spec }
|
|
59
|
+
: null;
|
|
17
60
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
61
|
+
const m = /^([a-z0-9][a-z0-9._-]*)(?:\[[^\]]*\])?(.*)$/i.exec(raw);
|
|
62
|
+
if (!m)
|
|
63
|
+
return null;
|
|
64
|
+
const name = (m[1] ?? "").toLowerCase().replace(/[._]+/g, "-");
|
|
65
|
+
const rest = (m[2] ?? "").split(";")[0]?.trim() ?? "";
|
|
66
|
+
if (rest.startsWith("@"))
|
|
67
|
+
return null; // PEP 508 URL
|
|
68
|
+
const pin = /^===?\s*([^\s,*]+)$/.exec(rest);
|
|
69
|
+
return { name, spec: pin ? pin[1] : rest || undefined };
|
|
70
|
+
}
|
|
71
|
+
function tokenize(segment) {
|
|
72
|
+
const tokens = segment
|
|
73
|
+
.trim()
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.map((t) => t.replace(/^['"]|['"]$/g, ""))
|
|
76
|
+
.filter((t) => t.length > 0);
|
|
77
|
+
while (tokens[0] === "sudo" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? ""))
|
|
78
|
+
tokens.shift();
|
|
79
|
+
return tokens;
|
|
20
80
|
}
|
|
21
|
-
|
|
81
|
+
function segments(command) {
|
|
82
|
+
return command.split(/(?:&&|\|\||[;|\n])/);
|
|
83
|
+
}
|
|
84
|
+
const VALUE_FLAGS = /^(?:-r|-c|-e|--requirement|--constraint|--editable|--registry|-i|--index-url|--extra-index-url|--filter|-F|-C|--dir|--prefix|-w|--workspace|--python|-p|--package|--with|--from)$/;
|
|
85
|
+
/** Packages named by install commands in `command`. Local paths/URLs/git are skipped. */
|
|
22
86
|
export function parsePackageInstalls(command) {
|
|
23
87
|
const refs = [];
|
|
24
|
-
for (const segment of command
|
|
25
|
-
const
|
|
26
|
-
.trim()
|
|
27
|
-
.split(/\s+/)
|
|
28
|
-
.map((t) => t.replace(/^['"]|['"]$/g, ""))
|
|
29
|
-
.filter((t) => t.length > 0);
|
|
30
|
-
if (tokens[0] === "sudo")
|
|
31
|
-
tokens.shift();
|
|
32
|
-
const [program, sub, ...rest] = tokens;
|
|
88
|
+
for (const segment of segments(command)) {
|
|
89
|
+
const [program, sub, ...rest] = tokenize(segment);
|
|
33
90
|
let ecosystem = null;
|
|
91
|
+
let manager = "npm";
|
|
34
92
|
let args = [];
|
|
35
|
-
|
|
93
|
+
let first = false;
|
|
94
|
+
if ((program === "npx" || program === "bunx") && sub !== undefined) {
|
|
36
95
|
ecosystem = "npm";
|
|
37
|
-
|
|
96
|
+
manager = program === "npx" ? "npx" : "bun";
|
|
97
|
+
args = [sub, ...rest];
|
|
98
|
+
first = true;
|
|
99
|
+
}
|
|
100
|
+
else if ((program === "pnpm" || program === "yarn" || program === "npm") &&
|
|
101
|
+
(sub === "dlx" || sub === "exec") &&
|
|
102
|
+
program !== "yarn") {
|
|
103
|
+
ecosystem = "npm";
|
|
104
|
+
manager = program === "npm" ? "npx" : "pnpm";
|
|
105
|
+
args = rest;
|
|
106
|
+
first = true;
|
|
38
107
|
}
|
|
39
108
|
else if ((program === "npm" || program === "pnpm" || program === "yarn" || program === "bun") &&
|
|
40
109
|
sub !== undefined &&
|
|
41
110
|
NPM_INSTALL.has(sub)) {
|
|
42
111
|
ecosystem = "npm";
|
|
112
|
+
manager = program;
|
|
43
113
|
args = rest;
|
|
44
114
|
}
|
|
45
115
|
else if ((program === "pip" || program === "pip3") && sub === "install") {
|
|
46
116
|
ecosystem = "PyPI";
|
|
117
|
+
manager = "pip";
|
|
118
|
+
args = rest;
|
|
119
|
+
}
|
|
120
|
+
else if (program === "pipx" && (sub === "run" || sub === "install")) {
|
|
121
|
+
ecosystem = "PyPI";
|
|
122
|
+
manager = "pip";
|
|
47
123
|
args = rest;
|
|
124
|
+
first = sub === "run";
|
|
48
125
|
}
|
|
49
126
|
else if (program === "uv" && sub === "pip" && rest[0] === "install") {
|
|
50
127
|
ecosystem = "PyPI";
|
|
128
|
+
manager = "uv";
|
|
129
|
+
args = rest.slice(1);
|
|
130
|
+
}
|
|
131
|
+
else if (program === "uv" && sub === "add") {
|
|
132
|
+
ecosystem = "PyPI";
|
|
133
|
+
manager = "uv";
|
|
134
|
+
args = rest;
|
|
135
|
+
}
|
|
136
|
+
else if (program === "uvx" && sub !== undefined) {
|
|
137
|
+
ecosystem = "PyPI";
|
|
138
|
+
manager = "uvx";
|
|
139
|
+
args = [sub, ...rest];
|
|
140
|
+
first = true;
|
|
141
|
+
}
|
|
142
|
+
else if (program === "uv" && sub === "tool" && (rest[0] === "run" || rest[0] === "install")) {
|
|
143
|
+
ecosystem = "PyPI";
|
|
144
|
+
manager = "uvx";
|
|
51
145
|
args = rest.slice(1);
|
|
146
|
+
first = rest[0] === "run";
|
|
52
147
|
}
|
|
53
148
|
if (!ecosystem)
|
|
54
149
|
continue;
|
|
@@ -58,20 +153,303 @@ export function parsePackageInstalls(command) {
|
|
|
58
153
|
skipNext = false;
|
|
59
154
|
continue;
|
|
60
155
|
}
|
|
61
|
-
|
|
62
|
-
|
|
156
|
+
if (arg === "--") {
|
|
157
|
+
if (first)
|
|
158
|
+
break;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (VALUE_FLAGS.test(arg)) {
|
|
63
162
|
skipNext = true;
|
|
64
163
|
continue;
|
|
65
164
|
}
|
|
66
|
-
if (arg.startsWith("-")
|
|
165
|
+
if (arg.startsWith("-"))
|
|
67
166
|
continue;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
167
|
+
const local = /[/\\:]/.test(arg.replace(/^@[^/]+\//, "")) || arg.startsWith(".");
|
|
168
|
+
const parsed = local ? null : splitSpec(arg, ecosystem);
|
|
169
|
+
if (parsed)
|
|
170
|
+
refs.push({ ecosystem, manager, ...parsed });
|
|
171
|
+
if (first)
|
|
172
|
+
break; // npx/uvx: only the package positional
|
|
71
173
|
}
|
|
72
174
|
}
|
|
73
175
|
return refs;
|
|
74
176
|
}
|
|
177
|
+
/** No-arg installs that install a whole lockfile. */
|
|
178
|
+
export function parseLockfileInstalls(command) {
|
|
179
|
+
const out = [];
|
|
180
|
+
let dir = "";
|
|
181
|
+
for (const segment of segments(command)) {
|
|
182
|
+
const tokens = tokenize(segment);
|
|
183
|
+
const [program, sub, ...rest] = tokens;
|
|
184
|
+
if (program === "cd" && sub !== undefined) {
|
|
185
|
+
dir = path.join(dir, sub);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const positional = rest.filter((a) => !a.startsWith("-"));
|
|
189
|
+
const at = (file) => path.join(dir, file);
|
|
190
|
+
if (program === "npm" &&
|
|
191
|
+
(sub === "ci" || ((sub === "install" || sub === "i") && positional.length === 0))) {
|
|
192
|
+
out.push({ ecosystem: "npm", manager: "npm", file: at("package-lock.json") });
|
|
193
|
+
}
|
|
194
|
+
else if (program === "pnpm" &&
|
|
195
|
+
(sub === "install" || sub === "i") &&
|
|
196
|
+
positional.length === 0) {
|
|
197
|
+
out.push({ ecosystem: "npm", manager: "pnpm", file: at("pnpm-lock.yaml") });
|
|
198
|
+
}
|
|
199
|
+
else if (program === "yarn" &&
|
|
200
|
+
(sub === undefined || sub.startsWith("-") || (sub === "install" && positional.length === 0))) {
|
|
201
|
+
out.push({ ecosystem: "npm", manager: "yarn", file: at("yarn.lock") });
|
|
202
|
+
}
|
|
203
|
+
else if (program === "bun" && (sub === "install" || sub === "i") && positional.length === 0) {
|
|
204
|
+
out.push({ ecosystem: "npm", manager: "bun", file: at("bun.lock") });
|
|
205
|
+
}
|
|
206
|
+
else if (program === "uv" && sub === "sync") {
|
|
207
|
+
const resolves = rest.some((a) => a === "--upgrade" || a.startsWith("--upgrade-package") || a === "-U" || a === "-P");
|
|
208
|
+
out.push({ ecosystem: "PyPI", manager: "uv", file: at("uv.lock"), resolves });
|
|
209
|
+
}
|
|
210
|
+
else if (((program === "pip" || program === "pip3") && sub === "install") ||
|
|
211
|
+
(program === "uv" && sub === "pip" && (rest[0] === "install" || rest[0] === "sync"))) {
|
|
212
|
+
const args = program === "uv" ? rest.slice(1) : rest;
|
|
213
|
+
for (let i = 0; i < args.length; i++) {
|
|
214
|
+
const a = args[i] ?? "";
|
|
215
|
+
if ((a === "-r" || a === "--requirement") && args[i + 1]) {
|
|
216
|
+
out.push({
|
|
217
|
+
ecosystem: "PyPI",
|
|
218
|
+
manager: program === "uv" ? "uv" : "pip",
|
|
219
|
+
file: at(args[i + 1] ?? ""),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else if (a.startsWith("--requirement=")) {
|
|
223
|
+
out.push({ ecosystem: "PyPI", manager: "pip", file: at(a.slice(14)) });
|
|
224
|
+
}
|
|
225
|
+
else if (program === "uv" && rest[0] === "sync" && !a.startsWith("-")) {
|
|
226
|
+
out.push({ ecosystem: "PyPI", manager: "uv", file: at(a) });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
/** The line reader met a package-lock shape it can't read exactly. */
|
|
234
|
+
class UnsupportedLockShape extends Error {
|
|
235
|
+
}
|
|
236
|
+
/** Per-line parser for the line-oriented lockfile formats (state kept in the closure). */
|
|
237
|
+
function lineParser(base, add) {
|
|
238
|
+
if (base === "pnpm-lock.yaml") {
|
|
239
|
+
let inPackages = false;
|
|
240
|
+
return (line) => {
|
|
241
|
+
if (/^\S/.test(line))
|
|
242
|
+
inPackages = line === "packages:" || line === "snapshots:";
|
|
243
|
+
if (!inPackages)
|
|
244
|
+
return;
|
|
245
|
+
const m = /^ {2}'?\/?((?:@[^@/'\s]+\/)?[^@/'\s]+)@([^('\s:]+)/.exec(line);
|
|
246
|
+
if (m)
|
|
247
|
+
add(m[1] ?? "", m[2] ?? "");
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (base === "yarn.lock") {
|
|
251
|
+
let names = [];
|
|
252
|
+
return (line) => {
|
|
253
|
+
if (/^\S/.test(line) && line.endsWith(":")) {
|
|
254
|
+
names = line
|
|
255
|
+
.slice(0, -1)
|
|
256
|
+
.split(",")
|
|
257
|
+
.map((s) => s.trim().replace(/^"|"$/g, ""))
|
|
258
|
+
.map((s) => s.slice(0, s.indexOf("@", 1)))
|
|
259
|
+
.filter((s) => s.length > 0);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const v = /^ {2}version:? "?([^"\s]+)"?/.exec(line);
|
|
263
|
+
if (v && names[0])
|
|
264
|
+
add(names[0], v[1] ?? "");
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (base === "bun.lock") {
|
|
268
|
+
return (line) => {
|
|
269
|
+
const m = /^\s*"[^"]+":\s*\[\s*"((?:@[^@/"]+\/)?[^@/"]+)@([^"]+)"/.exec(line);
|
|
270
|
+
if (m)
|
|
271
|
+
add(m[1] ?? "", m[2] ?? "");
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (base === "uv.lock") {
|
|
275
|
+
let name = "";
|
|
276
|
+
return (line) => {
|
|
277
|
+
if (line === "[[package]]")
|
|
278
|
+
name = "";
|
|
279
|
+
const n = /^name = "([^"]+)"/.exec(line);
|
|
280
|
+
if (n)
|
|
281
|
+
name = n[1] ?? "";
|
|
282
|
+
const v = /^version = "([^"]+)"/.exec(line);
|
|
283
|
+
if (v && name)
|
|
284
|
+
add(name.replace(/[._]+/g, "-"), v[1] ?? "");
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (base === "package-lock.json" || base === "npm-shrinkwrap.json") {
|
|
288
|
+
// Pretty-printed (2-space) package-lock, read line by line so a huge one
|
|
289
|
+
// never needs one long JSON.parse. Same answers as the JSON walk.
|
|
290
|
+
let section = "";
|
|
291
|
+
let key = null;
|
|
292
|
+
let fields = {};
|
|
293
|
+
return (line) => {
|
|
294
|
+
const top = /^ {2}"([^"]+)": \{/.exec(line);
|
|
295
|
+
if (top) {
|
|
296
|
+
section = top[1] ?? "";
|
|
297
|
+
key = null;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (section !== "packages" && section !== "dependencies")
|
|
301
|
+
return;
|
|
302
|
+
const open = /^ {4}"((?:[^"\\]|\\.)*)": \{(\}?),?$/.exec(line);
|
|
303
|
+
// An entry line we don't fully recognise (hand-formatted, inline
|
|
304
|
+
// objects): let the caller fall back to the exact JSON walk.
|
|
305
|
+
if (!open && /^ {4}"/.test(line))
|
|
306
|
+
throw new UnsupportedLockShape();
|
|
307
|
+
if (open) {
|
|
308
|
+
key = open[1] ?? "";
|
|
309
|
+
fields = {};
|
|
310
|
+
if (!open[2])
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
else if (key !== null && /^ {4}\},?$/.test(line)) {
|
|
314
|
+
// close: fall through to emit
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
if (key === null)
|
|
318
|
+
return;
|
|
319
|
+
const f = /^ {6}"(version|name|link)": (?:"((?:[^"\\]|\\.)*)"|(true|false)),?$/.exec(line);
|
|
320
|
+
if (!f && /^ {6}"(?:version|name|link)"/.test(line))
|
|
321
|
+
throw new UnsupportedLockShape();
|
|
322
|
+
if (f?.[1] === "link")
|
|
323
|
+
fields.link = f[3] === "true";
|
|
324
|
+
else if (f?.[1] === "version")
|
|
325
|
+
fields.version = f[2];
|
|
326
|
+
else if (f?.[1] === "name")
|
|
327
|
+
fields.name = f[2];
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const k = key ?? "";
|
|
331
|
+
key = null;
|
|
332
|
+
if (section === "dependencies") {
|
|
333
|
+
add(k, fields.version ?? "");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const idx = k.lastIndexOf("node_modules/");
|
|
337
|
+
if (idx < 0 || fields.link || !fields.version)
|
|
338
|
+
return;
|
|
339
|
+
add(fields.name ?? k.slice(idx + 13), fields.version);
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// requirements file: only exact pins
|
|
343
|
+
return (line) => {
|
|
344
|
+
const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s;#,]+)/.exec(line);
|
|
345
|
+
if (m)
|
|
346
|
+
add((m[1] ?? "").replace(/[._]+/g, "-"), m[2] ?? "");
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function lockedCollector() {
|
|
350
|
+
const out = new Map();
|
|
351
|
+
return {
|
|
352
|
+
out,
|
|
353
|
+
add: (name, version) => {
|
|
354
|
+
if (!name || !/^\d/.test(version))
|
|
355
|
+
return;
|
|
356
|
+
out.set(`${name}@${version}`, { name: name.toLowerCase(), version });
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const stripBom = (raw) => (raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
361
|
+
const isNpmLock = (base) => base === "package-lock.json" || base === "npm-shrinkwrap.json";
|
|
362
|
+
/** A package-lock the line reader handles: 2-space pretty JSON that is complete. */
|
|
363
|
+
function npmLockByLines(text) {
|
|
364
|
+
return /^\{\r?\n {2}"/.test(text) && /\}\s*$/.test(text);
|
|
365
|
+
}
|
|
366
|
+
/** JSON walk of a package-lock (minified or odd indentation). */
|
|
367
|
+
function parseNpmLockJson(text, add) {
|
|
368
|
+
try {
|
|
369
|
+
const json = JSON.parse(text);
|
|
370
|
+
for (const [key, v] of Object.entries(json.packages ?? {})) {
|
|
371
|
+
const idx = key.lastIndexOf("node_modules/");
|
|
372
|
+
if (idx < 0 || v.link || !v.version)
|
|
373
|
+
continue;
|
|
374
|
+
add(v.name ?? key.slice(idx + 13), v.version);
|
|
375
|
+
}
|
|
376
|
+
for (const [name, v] of Object.entries(json.dependencies ?? {}))
|
|
377
|
+
add(name, v.version ?? "");
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Parse name@version pairs from a lockfile's text. Unknown shapes yield []. */
|
|
385
|
+
export function parseLockfile(file, raw) {
|
|
386
|
+
const text = stripBom(raw); // BOM (Windows editors)
|
|
387
|
+
const base = path.basename(file);
|
|
388
|
+
const { add, out } = lockedCollector();
|
|
389
|
+
if (isNpmLock(base))
|
|
390
|
+
return parseNpmLockJson(text, add) ? [...out.values()] : [];
|
|
391
|
+
const onLine = lineParser(base, add);
|
|
392
|
+
for (const line of text.split(/\r?\n/))
|
|
393
|
+
onLine(line);
|
|
394
|
+
return [...out.values()];
|
|
395
|
+
}
|
|
396
|
+
const SLICE_MS = 4;
|
|
397
|
+
const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
398
|
+
/**
|
|
399
|
+
* `parseLockfile` for the event loop's sake: walks lines in slices of a few
|
|
400
|
+
* ms with a yield in between, so a 50k-entry lockfile never stalls the
|
|
401
|
+
* sidecar (shared by every app window). Same results.
|
|
402
|
+
*/
|
|
403
|
+
export async function parseLockfileAsync(file, raw) {
|
|
404
|
+
const text = stripBom(raw);
|
|
405
|
+
const base = path.basename(file);
|
|
406
|
+
const { add, out } = lockedCollector();
|
|
407
|
+
if (isNpmLock(base) && !npmLockByLines(text)) {
|
|
408
|
+
// Truncated (no closing brace) can't be valid JSON: skip the long parse.
|
|
409
|
+
if (!/\}\s*$/.test(text))
|
|
410
|
+
return [];
|
|
411
|
+
return parseNpmLockJson(text, add) ? [...out.values()] : [];
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await readLinesInSlices(text, lineParser(base, add));
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
if (!(err instanceof UnsupportedLockShape))
|
|
418
|
+
throw err;
|
|
419
|
+
const exact = lockedCollector();
|
|
420
|
+
return parseNpmLockJson(text, exact.add) ? [...exact.out.values()] : [];
|
|
421
|
+
}
|
|
422
|
+
return [...out.values()];
|
|
423
|
+
}
|
|
424
|
+
/** Feed `text` line by line to `onLine`, yielding to the event loop every few ms. */
|
|
425
|
+
async function readLinesInSlices(text, onLine) {
|
|
426
|
+
let start = 0;
|
|
427
|
+
let sliceAt = performance.now();
|
|
428
|
+
let n = 0;
|
|
429
|
+
while (start <= text.length) {
|
|
430
|
+
const nl = text.indexOf("\n", start);
|
|
431
|
+
const end = nl < 0 ? text.length : nl;
|
|
432
|
+
const line = text.slice(start, end > start && text.charCodeAt(end - 1) === 13 ? end - 1 : end);
|
|
433
|
+
onLine(line);
|
|
434
|
+
start = end + 1;
|
|
435
|
+
if (++n % 128 === 0 && performance.now() - sliceAt > SLICE_MS) {
|
|
436
|
+
await yieldToLoop();
|
|
437
|
+
sliceAt = performance.now();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** sha256 of a big text in slices with yields (hashing 13 MB at once is ~20 ms). */
|
|
442
|
+
async function hashInSlices(text) {
|
|
443
|
+
const h = createHash("sha256");
|
|
444
|
+
const step = 1 << 20;
|
|
445
|
+
for (let i = 0; i < text.length; i += step) {
|
|
446
|
+
h.update(text.slice(i, i + step));
|
|
447
|
+
if (i + step < text.length)
|
|
448
|
+
await yieldToLoop();
|
|
449
|
+
}
|
|
450
|
+
return h.digest("hex");
|
|
451
|
+
}
|
|
452
|
+
// ── Typosquat (local candidate) ──────────────────────────
|
|
75
453
|
/** Optimal-string-alignment Damerau-Levenshtein distance. */
|
|
76
454
|
export function damerauLevenshtein(a, b) {
|
|
77
455
|
const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
@@ -91,78 +469,872 @@ export function damerauLevenshtein(a, b) {
|
|
|
91
469
|
}
|
|
92
470
|
return at(a.length, b.length);
|
|
93
471
|
}
|
|
94
|
-
|
|
472
|
+
const popularSets = {
|
|
473
|
+
npm: new Set(POPULAR_NPM_PACKAGES),
|
|
474
|
+
PyPI: new Set(POPULAR_PYPI_PACKAGES),
|
|
475
|
+
};
|
|
476
|
+
/** The popular name `ref` is one edit away from, or null. Local only. */
|
|
477
|
+
export function typosquatCandidate(ref) {
|
|
95
478
|
const popular = ref.ecosystem === "npm" ? POPULAR_NPM_PACKAGES : POPULAR_PYPI_PACKAGES;
|
|
96
|
-
if (
|
|
479
|
+
if (popularSets[ref.ecosystem].has(ref.name))
|
|
97
480
|
return null;
|
|
98
481
|
return (popular.find((p) => Math.abs(p.length - ref.name.length) <= 1 && damerauLevenshtein(p, ref.name) === 1) ?? null);
|
|
99
482
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
osvCache.set(key(r), ids);
|
|
125
|
-
});
|
|
483
|
+
function isExact(spec) {
|
|
484
|
+
return spec !== undefined && /^v?\d+(?:\.\d+)*(?:[-.+][0-9A-Za-z.-]+)?$/.test(spec);
|
|
485
|
+
}
|
|
486
|
+
function label(ref, version) {
|
|
487
|
+
return version ? `${ref.name}@${version}` : ref.name;
|
|
488
|
+
}
|
|
489
|
+
/** Instant part of the check: parsing, typosquat candidates and local feed hits. */
|
|
490
|
+
export function localPackageCheck(command, feedOrLoad) {
|
|
491
|
+
const refs = parsePackageInstalls(command);
|
|
492
|
+
const lockfiles = refs.length === 0 ? parseLockfileInstalls(command) : [];
|
|
493
|
+
if (refs.length === 0 && lockfiles.length === 0) {
|
|
494
|
+
return { refs, lockfiles, threats: [], typosquats: [], flagged: new Set(), feed: emptyFeed() };
|
|
495
|
+
}
|
|
496
|
+
const feed = typeof feedOrLoad === "function" ? feedOrLoad() : feedOrLoad;
|
|
497
|
+
const threats = [];
|
|
498
|
+
const typosquats = [];
|
|
499
|
+
const flagged = new Set();
|
|
500
|
+
for (const ref of refs) {
|
|
501
|
+
const version = isExact(ref.spec) ? ref.spec.replace(/^v/, "") : undefined;
|
|
502
|
+
const ids = feedMatches(feed, ref.ecosystem, ref.name, version);
|
|
503
|
+
if (ids.length > 0) {
|
|
504
|
+
threats.push(malwareThreat(ref, version, ids, null, 0));
|
|
505
|
+
flagged.add(ref.name);
|
|
506
|
+
continue;
|
|
126
507
|
}
|
|
127
|
-
|
|
128
|
-
|
|
508
|
+
const popular = typosquatCandidate(ref);
|
|
509
|
+
if (popular)
|
|
510
|
+
typosquats.push({ ref, popular });
|
|
511
|
+
}
|
|
512
|
+
return { refs, lockfiles, threats, typosquats, flagged, feed };
|
|
513
|
+
}
|
|
514
|
+
function malwareThreat(ref, version, ids, safe, days) {
|
|
515
|
+
const instead = safe
|
|
516
|
+
? `use ${ref.name}@${safe}, the last version older than ${days} days`
|
|
517
|
+
: "pick a different version or package";
|
|
518
|
+
return {
|
|
519
|
+
rule: "malicious-package",
|
|
520
|
+
severity: "block",
|
|
521
|
+
detail: `${label(ref, version)} is flagged as malware (${ids.join(", ")}); ${instead}.`,
|
|
522
|
+
blocked: [
|
|
523
|
+
{
|
|
524
|
+
ecosystem: ref.ecosystem,
|
|
525
|
+
name: ref.name,
|
|
526
|
+
...(version ? { version } : {}),
|
|
527
|
+
reason: `known malware (${ids.join(", ")})`,
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
class NotFound {
|
|
533
|
+
}
|
|
534
|
+
async function getJson(url, ctx, headers = {}) {
|
|
535
|
+
const key = `${headers.accept ?? ""} ${url}`;
|
|
536
|
+
let hit = metaCache.get(key);
|
|
537
|
+
if (!hit) {
|
|
538
|
+
hit = fetchJson(url, { headers }, ctx, true);
|
|
539
|
+
metaCache.set(key, hit);
|
|
540
|
+
// A failed fetch should be retried next install, not cached.
|
|
541
|
+
hit.catch(() => metaCache.delete(key));
|
|
542
|
+
}
|
|
543
|
+
return hit;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Hosts that just failed or stalled: skipped (fail-open, instantly) for a
|
|
547
|
+
* short while so an offline machine or a hung registry costs the deadline
|
|
548
|
+
* once, not on every install.
|
|
549
|
+
*/
|
|
550
|
+
const downHosts = new Map();
|
|
551
|
+
const DOWN_MS = 2 * 60_000;
|
|
552
|
+
/** Key for "nothing answered before the deadline" (offline, dead proxy): every host waits. */
|
|
553
|
+
const ALL_HOSTS = "*";
|
|
554
|
+
/** After a deadline cut: if no host answered at all, the network itself is down for a while. */
|
|
555
|
+
function noteDeadline(ctx) {
|
|
556
|
+
if (!ctx.cancelled() && !ctx.reached.value)
|
|
557
|
+
downHosts.set(ALL_HOSTS, Date.now() + DOWN_MS);
|
|
558
|
+
}
|
|
559
|
+
function hostOf(url) {
|
|
560
|
+
try {
|
|
561
|
+
return new URL(url).host;
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return url;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** fetch + status + JSON, honouring and feeding the down-host memory. 404 → NotFound when asked. */
|
|
568
|
+
async function fetchJson(url, init, ctx, notFound = false) {
|
|
569
|
+
const host = hostOf(url);
|
|
570
|
+
const down = Math.max(downHosts.get(host) ?? 0, downHosts.get(ALL_HOSTS) ?? 0);
|
|
571
|
+
if (Date.now() < down)
|
|
572
|
+
throw new Error(`${host} recently unreachable`);
|
|
573
|
+
try {
|
|
574
|
+
// Raced with the signal too: a fetch that ignores aborts must not hang us.
|
|
575
|
+
return await Promise.race([
|
|
576
|
+
(async () => {
|
|
577
|
+
const res = await ctx.fetch(url, { ...init, signal: ctx.signal });
|
|
578
|
+
if (res.status < 500)
|
|
579
|
+
ctx.reached.value = true;
|
|
580
|
+
if (notFound && res.status === 404)
|
|
581
|
+
return new NotFound();
|
|
582
|
+
if (!res.ok)
|
|
583
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
584
|
+
return res.json();
|
|
585
|
+
})(),
|
|
586
|
+
deadline(ctx.signal),
|
|
587
|
+
]);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
// A cancel by the caller says nothing about the host; a network error,
|
|
591
|
+
// 4xx other than 404 (429, proxy 403/407), 5xx, bad JSON or our deadline does.
|
|
592
|
+
if (!ctx.cancelled())
|
|
593
|
+
downHosts.set(host, Date.now() + DOWN_MS);
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const enc = (name) => name.replace("/", "%2F");
|
|
598
|
+
/** Abbreviated packument (no publish times); the full one only when times are needed. */
|
|
599
|
+
function npmDoc(name, full, ctx) {
|
|
600
|
+
const accept = full ? "application/json" : "application/vnd.npm.install-v1+json";
|
|
601
|
+
// A full doc already fetched answers abbreviated questions too.
|
|
602
|
+
const fullKey = `application/json ${NPM_REGISTRY}/${enc(name)}`;
|
|
603
|
+
if (!full && metaCache.has(fullKey))
|
|
604
|
+
return getJson(`${NPM_REGISTRY}/${enc(name)}`, ctx, { accept: "application/json" });
|
|
605
|
+
return getJson(`${NPM_REGISTRY}/${enc(name)}`, ctx, { accept });
|
|
606
|
+
}
|
|
607
|
+
function pypiDoc(name, ctx) {
|
|
608
|
+
return getJson(`${PYPI}/${name}/json`, ctx);
|
|
609
|
+
}
|
|
610
|
+
function isStable(v) {
|
|
611
|
+
return /^\d+(?:\.\d+)*$/.test(v);
|
|
612
|
+
}
|
|
613
|
+
/** Highest version satisfying a simple npm range (exact, ^, ~, x, >=). Null if unsupported. */
|
|
614
|
+
function maxSatisfying(versions, range) {
|
|
615
|
+
const r = range.trim();
|
|
616
|
+
const m = /^(\^|~|>=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/.exec(r);
|
|
617
|
+
if (r === "*" || r === "x" || r === "")
|
|
618
|
+
return pickMax(versions.filter(isStable));
|
|
619
|
+
if (!m)
|
|
620
|
+
return null;
|
|
621
|
+
const op = m[1] ?? "";
|
|
622
|
+
const nums = [m[2], m[3], m[4]].map((n) => n === undefined || n === "x" || n === "*" ? null : Number(n));
|
|
623
|
+
const [maj, min, pat] = nums;
|
|
624
|
+
const ok = (v) => {
|
|
625
|
+
if (!isStable(v))
|
|
626
|
+
return false;
|
|
627
|
+
const [a = 0, b = 0, c = 0] = v.split(".").map(Number);
|
|
628
|
+
const lo = `${maj ?? 0}.${min ?? 0}.${pat ?? 0}`;
|
|
629
|
+
if (compareVersions(v, lo) < 0)
|
|
630
|
+
return false;
|
|
631
|
+
if (op === ">=")
|
|
632
|
+
return true;
|
|
633
|
+
if (op === "^") {
|
|
634
|
+
if ((maj ?? 0) > 0)
|
|
635
|
+
return a === maj;
|
|
636
|
+
if ((min ?? 0) > 0)
|
|
637
|
+
return a === 0 && b === min;
|
|
638
|
+
return a === 0 && b === 0 && (pat === null || c === pat);
|
|
129
639
|
}
|
|
640
|
+
if (op === "~")
|
|
641
|
+
return a === maj && (min === null || b === min);
|
|
642
|
+
return a === maj && (min === null || b === min) && (pat === null || c === pat);
|
|
643
|
+
};
|
|
644
|
+
return pickMax(versions.filter(ok));
|
|
645
|
+
}
|
|
646
|
+
function pickMax(versions) {
|
|
647
|
+
let best = null;
|
|
648
|
+
for (const v of versions)
|
|
649
|
+
if (best === null || compareVersions(v, best) > 0)
|
|
650
|
+
best = v;
|
|
651
|
+
return best;
|
|
652
|
+
}
|
|
653
|
+
async function resolveRef(ref, full, ctx) {
|
|
654
|
+
if (ref.ecosystem === "npm") {
|
|
655
|
+
const doc = await npmDoc(ref.name, full, ctx);
|
|
656
|
+
if (doc instanceof NotFound)
|
|
657
|
+
return { ref, version: null, missing: true };
|
|
658
|
+
const npm = doc;
|
|
659
|
+
const tags = npm["dist-tags"] ?? {};
|
|
660
|
+
const all = Object.keys(npm.versions ?? {});
|
|
661
|
+
let version;
|
|
662
|
+
if (ref.spec === undefined)
|
|
663
|
+
version = tags.latest ?? null;
|
|
664
|
+
else if (tags[ref.spec])
|
|
665
|
+
version = tags[ref.spec] ?? null;
|
|
666
|
+
else if (isExact(ref.spec))
|
|
667
|
+
version = ref.spec.replace(/^v/, "");
|
|
668
|
+
else
|
|
669
|
+
version = maxSatisfying(all, ref.spec);
|
|
670
|
+
return { ref, version, missing: false, npm, full: npm.time !== undefined };
|
|
130
671
|
}
|
|
672
|
+
const doc = await pypiDoc(ref.name, ctx);
|
|
673
|
+
if (doc instanceof NotFound)
|
|
674
|
+
return { ref, version: null, missing: true };
|
|
675
|
+
const pypi = doc;
|
|
676
|
+
const version = ref.spec === undefined ? (pypi.info?.version ?? null) : isExact(ref.spec) ? ref.spec : null;
|
|
677
|
+
return { ref, version, missing: false, pypi };
|
|
678
|
+
}
|
|
679
|
+
/** Publish time (ms) per version. npm needs the full packument (fetched only when required). */
|
|
680
|
+
async function publishTimes(r, ctx) {
|
|
131
681
|
const out = new Map();
|
|
132
|
-
|
|
133
|
-
const
|
|
682
|
+
if (r.ref.ecosystem === "npm") {
|
|
683
|
+
const full = r.full ? r.npm : await npmDoc(r.ref.name, true, ctx);
|
|
684
|
+
if (!full || full instanceof NotFound)
|
|
685
|
+
return out;
|
|
686
|
+
for (const [v, t] of Object.entries(full.time ?? {})) {
|
|
687
|
+
if (v === "created" || v === "modified")
|
|
688
|
+
continue;
|
|
689
|
+
out.set(v, Date.parse(t));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
for (const [v, files] of Object.entries(r.pypi?.releases ?? {})) {
|
|
694
|
+
const times = files
|
|
695
|
+
.filter((f) => !f.yanked)
|
|
696
|
+
.map((f) => Date.parse(f.upload_time_iso_8601 ?? ""))
|
|
697
|
+
.filter((t) => !Number.isNaN(t));
|
|
698
|
+
if (times.length > 0)
|
|
699
|
+
out.set(v, Math.min(...times));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
703
|
+
}
|
|
704
|
+
/** Newest stable version published before the cutoff, skipping `bad`. */
|
|
705
|
+
/** The compatible release line of a version, as `^` reads it: `5` for 5.6.1, `0.30` for 0.30.4. */
|
|
706
|
+
function releaseLine(version) {
|
|
707
|
+
const [major = "", minor = ""] = version.replace(/^v/, "").split(".");
|
|
708
|
+
return major === "0" ? `0.${minor}` : major;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The version to suggest instead: the newest stable release older than
|
|
712
|
+
* `cutoff`, not in `bad`, preferring the same release line as `near` so the
|
|
713
|
+
* agent isn't steered into a breaking major upgrade. Falls back to any line.
|
|
714
|
+
*/
|
|
715
|
+
function lastOlderThan(times, cutoff, bad, near) {
|
|
716
|
+
const ok = [...times]
|
|
717
|
+
.filter(([v, t]) => t <= cutoff && isStable(v) && !bad.has(v))
|
|
718
|
+
.map(([v]) => v);
|
|
719
|
+
if (near) {
|
|
720
|
+
const line = releaseLine(near);
|
|
721
|
+
const same = pickMax(ok.filter((v) => releaseLine(v) === line));
|
|
722
|
+
if (same)
|
|
723
|
+
return same;
|
|
724
|
+
}
|
|
725
|
+
return pickMax(ok);
|
|
726
|
+
}
|
|
727
|
+
/** Versions of a package the local feed flags, so a suggestion never names one. */
|
|
728
|
+
function feedFlagged(ctx, ref, versions) {
|
|
729
|
+
const out = new Set();
|
|
730
|
+
for (const v of versions) {
|
|
731
|
+
if (feedMatches(ctx.feed, ref.ecosystem, ref.name, v).length > 0)
|
|
732
|
+
out.add(v);
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
const VERDICT_FILE = "package-verdicts.json";
|
|
737
|
+
const OSV_TTL_MS = DAY_MS;
|
|
738
|
+
const MAX_LOCKS = 50;
|
|
739
|
+
class VerdictCache {
|
|
740
|
+
file;
|
|
741
|
+
data = { osvClean: {}, osvFlawed: {}, locks: {} };
|
|
742
|
+
loaded = null;
|
|
743
|
+
saving = Promise.resolve();
|
|
744
|
+
dirty = false;
|
|
745
|
+
constructor(file) {
|
|
746
|
+
this.file = file;
|
|
747
|
+
}
|
|
748
|
+
load() {
|
|
749
|
+
if (!this.loaded) {
|
|
750
|
+
this.loaded = this.file
|
|
751
|
+
? fsp
|
|
752
|
+
.readFile(this.file, "utf8")
|
|
753
|
+
.then((text) => {
|
|
754
|
+
const raw = JSON.parse(text);
|
|
755
|
+
this.data = {
|
|
756
|
+
osvClean: raw.osvClean ?? {},
|
|
757
|
+
osvFlawed: raw.osvFlawed ?? {},
|
|
758
|
+
locks: raw.locks ?? {},
|
|
759
|
+
};
|
|
760
|
+
})
|
|
761
|
+
.catch(() => undefined)
|
|
762
|
+
: Promise.resolve();
|
|
763
|
+
}
|
|
764
|
+
return this.loaded;
|
|
765
|
+
}
|
|
766
|
+
osvFresh(key, now) {
|
|
767
|
+
const at = this.data.osvClean[key];
|
|
768
|
+
return at !== undefined && now - at < OSV_TTL_MS;
|
|
769
|
+
}
|
|
770
|
+
markClean(key, now, flawed = false) {
|
|
771
|
+
this.data.osvClean[key] = now;
|
|
772
|
+
const f = (this.data.osvFlawed ??= {});
|
|
773
|
+
if (flawed)
|
|
774
|
+
f[key] = now;
|
|
775
|
+
else
|
|
776
|
+
delete f[key];
|
|
777
|
+
this.dirty = true;
|
|
778
|
+
}
|
|
779
|
+
/** A remembered no-malware answer that also had advisories. */
|
|
780
|
+
osvFlawed(key, now) {
|
|
781
|
+
const at = this.data.osvFlawed?.[key];
|
|
782
|
+
return at !== undefined && now - at < OSV_TTL_MS;
|
|
783
|
+
}
|
|
784
|
+
lock(key) {
|
|
785
|
+
return this.data.locks[key];
|
|
786
|
+
}
|
|
787
|
+
setLock(key, v) {
|
|
788
|
+
this.data.locks[key] = v;
|
|
789
|
+
this.dirty = true;
|
|
790
|
+
}
|
|
791
|
+
/** Write in the background (atomic rename); never on the caller's critical path. */
|
|
792
|
+
save(now) {
|
|
793
|
+
if (!this.file || !this.dirty)
|
|
794
|
+
return;
|
|
795
|
+
this.dirty = false;
|
|
796
|
+
const file = this.file;
|
|
797
|
+
this.saving = this.saving.then(async () => {
|
|
798
|
+
const osvClean = Object.fromEntries(Object.entries(this.data.osvClean).filter(([, at]) => now - at < OSV_TTL_MS));
|
|
799
|
+
const osvFlawed = Object.fromEntries(Object.entries(this.data.osvFlawed ?? {}).filter(([k]) => k in osvClean));
|
|
800
|
+
const locks = Object.fromEntries(Object.entries(this.data.locks)
|
|
801
|
+
.sort(([, a], [, b]) => b.at - a.at)
|
|
802
|
+
.slice(0, MAX_LOCKS));
|
|
803
|
+
this.data = { osvClean, osvFlawed, locks };
|
|
804
|
+
try {
|
|
805
|
+
await writeJsonAtomic(file, this.data);
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
log("WARN", "package-threats", `verdict cache write failed: ${String(err)}`);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
flush() {
|
|
813
|
+
return this.saving;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const verdictCaches = new Map();
|
|
817
|
+
function verdictCache(dir) {
|
|
818
|
+
const file = dir === null ? null : path.join(dir, VERDICT_FILE);
|
|
819
|
+
const key = file ?? "";
|
|
820
|
+
let hit = verdictCaches.get(key);
|
|
821
|
+
if (!hit) {
|
|
822
|
+
hit = new VerdictCache(file);
|
|
823
|
+
verdictCaches.set(key, hit);
|
|
824
|
+
}
|
|
825
|
+
return hit;
|
|
826
|
+
}
|
|
827
|
+
const osvKey = (i) => `${i.ecosystem}:${i.name}@${i.version}`;
|
|
828
|
+
/** MAL- IDs per `eco:name@version`, one request per 1000 queries, cached (memory + disk). */
|
|
829
|
+
/** A remembered OSV answer still holds: malware stays malware; "clean" is re-asked daily. */
|
|
830
|
+
function osvKnown(key, now) {
|
|
831
|
+
const hit = osvCache.get(key);
|
|
832
|
+
if (!hit)
|
|
833
|
+
return false;
|
|
834
|
+
return hit.ids.length > 0 || now - hit.at < OSV_TTL_MS;
|
|
835
|
+
}
|
|
836
|
+
async function osvMalware(items, ctx) {
|
|
837
|
+
const pending = [
|
|
838
|
+
...new Map(items
|
|
839
|
+
.filter((i) => !osvKnown(osvKey(i), ctx.now) && !ctx.cache.osvFresh(osvKey(i), ctx.now))
|
|
840
|
+
.map((i) => [osvKey(i), i])).values(),
|
|
841
|
+
];
|
|
842
|
+
for (let start = 0; start < pending.length; start += OSV_BATCH) {
|
|
843
|
+
const batch = pending.slice(start, start + OSV_BATCH);
|
|
844
|
+
const body = (await fetchJson(OSV_URL, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers: { "content-type": "application/json" },
|
|
847
|
+
body: JSON.stringify({
|
|
848
|
+
queries: batch.map((i) => ({
|
|
849
|
+
package: { ecosystem: i.ecosystem, name: i.name },
|
|
850
|
+
version: i.version,
|
|
851
|
+
})),
|
|
852
|
+
}),
|
|
853
|
+
}, ctx));
|
|
854
|
+
batch.forEach((item, i) => {
|
|
855
|
+
const all = (body.results?.[i]?.vulns ?? []).map((v) => v.id ?? "");
|
|
856
|
+
const ids = all.filter((id) => id.startsWith("MAL-"));
|
|
857
|
+
const flawed = all.some((id) => id !== "" && !id.startsWith("MAL-"));
|
|
858
|
+
osvCache.set(osvKey(item), { ids, flawed, at: ctx.now });
|
|
859
|
+
// Remembered on disk with whether it had advisories, so a restart
|
|
860
|
+
// neither re-asks OSV nor loses the serious-flaw trigger.
|
|
861
|
+
if (ids.length === 0)
|
|
862
|
+
ctx.cache.markClean(osvKey(item), ctx.now, flawed);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
const out = new Map();
|
|
866
|
+
for (const i of items) {
|
|
867
|
+
const ids = osvCache.get(osvKey(i))?.ids;
|
|
134
868
|
if (ids && ids.length > 0)
|
|
135
|
-
out.set(
|
|
869
|
+
out.set(osvKey(i), ids);
|
|
136
870
|
}
|
|
137
871
|
return out;
|
|
138
872
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
873
|
+
// ── Serious known flaws ─────────────────────────────────
|
|
874
|
+
/**
|
|
875
|
+
* Major version, the boundary for a flaw fix: moving 0.21 → 0.34 or 4.17 →
|
|
876
|
+
* 4.18 is the normal upgrade path; 0.x → 1.x or 4 → 5 is the breaking one.
|
|
877
|
+
* (Not `releaseLine`: under `^`, 0.21 and 0.31 count as different lines, which
|
|
878
|
+
* would leave old 0.x packages with no fix to move to.)
|
|
879
|
+
*/
|
|
880
|
+
function majorOf(version) {
|
|
881
|
+
return version.replace(/^v/, "").split(".")[0] ?? "";
|
|
882
|
+
}
|
|
883
|
+
const SERIOUS = new Set(["HIGH", "CRITICAL"]);
|
|
884
|
+
/** Did the batch lookup see any non-malware advisory for this exact version? */
|
|
885
|
+
function osvFlawed(item, ctx) {
|
|
886
|
+
const key = osvKey(item);
|
|
887
|
+
const hit = osvCache.get(key);
|
|
888
|
+
return hit ? hit.flawed : ctx.cache.osvFlawed(key, ctx.now);
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Serious advisories for one exact version (one detailed OSV request, only
|
|
892
|
+
* made when the batch lookup already saw advisories). Cached for a day.
|
|
893
|
+
*/
|
|
894
|
+
async function seriousFlaws(item, ctx) {
|
|
895
|
+
const key = osvKey(item);
|
|
896
|
+
const hit = vulnCache.get(key);
|
|
897
|
+
if (hit && ctx.now - hit.at < OSV_TTL_MS)
|
|
898
|
+
return hit.verdict;
|
|
899
|
+
const body = (await fetchJson(OSV_QUERY_URL, {
|
|
900
|
+
method: "POST",
|
|
901
|
+
headers: { "content-type": "application/json" },
|
|
902
|
+
body: JSON.stringify({
|
|
903
|
+
package: { ecosystem: item.ecosystem, name: item.name },
|
|
904
|
+
version: item.version,
|
|
905
|
+
}),
|
|
906
|
+
}, ctx));
|
|
907
|
+
const serious = (body.vulns ?? []).filter((v) => !(v.id ?? "").startsWith("MAL-") &&
|
|
908
|
+
SERIOUS.has((v.database_specific?.severity ?? "").toUpperCase()));
|
|
909
|
+
const verdict = serious.length === 0
|
|
910
|
+
? null
|
|
911
|
+
: {
|
|
912
|
+
ids: serious.map((v) => v.id ?? "").filter(Boolean),
|
|
913
|
+
summary: (serious.find((v) => v.database_specific?.severity?.toUpperCase() === "CRITICAL") ??
|
|
914
|
+
serious[0])?.summary?.trim() ?? "",
|
|
915
|
+
severity: serious.some((v) => v.database_specific?.severity?.toUpperCase() === "CRITICAL")
|
|
916
|
+
? "CRITICAL"
|
|
917
|
+
: "HIGH",
|
|
918
|
+
fixes: serious.map((v) => (v.affected ?? [])
|
|
919
|
+
.filter((a) => !a.package?.name || a.package.name.toLowerCase() === item.name.toLowerCase())
|
|
920
|
+
.flatMap((a) => a.ranges ?? [])
|
|
921
|
+
.flatMap((r) => r.events ?? [])
|
|
922
|
+
.map((e) => e.fixed)
|
|
923
|
+
.filter((f) => typeof f === "string" && f.length > 0)),
|
|
924
|
+
};
|
|
925
|
+
vulnCache.set(key, { verdict, at: ctx.now });
|
|
926
|
+
return verdict;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* The version to move to: the newest stable release on `version`'s release
|
|
930
|
+
* line that is at or past a fix of EVERY serious advisory (on that line),
|
|
931
|
+
* older than the cooldown, and not malware. Null when there is none (no fix
|
|
932
|
+
* yet, or only fixed in another major) — then the install is left alone.
|
|
933
|
+
*/
|
|
934
|
+
function fixedOnSameLine(version, verdict, times, cutoff, bad) {
|
|
935
|
+
const line = majorOf(version);
|
|
936
|
+
const floors = [];
|
|
937
|
+
for (const fixes of verdict.fixes) {
|
|
938
|
+
const onLine = fixes.filter((f) => majorOf(f) === line && compareVersions(f, version) > 0);
|
|
939
|
+
if (onLine.length === 0)
|
|
940
|
+
return null; // this flaw isn't fixed on this line
|
|
941
|
+
floors.push(onLine.reduce((a, b) => (compareVersions(a, b) <= 0 ? a : b)));
|
|
942
|
+
}
|
|
943
|
+
const floor = floors.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
|
|
944
|
+
const ok = [...times]
|
|
945
|
+
.filter(([v, t]) => t <= cutoff &&
|
|
946
|
+
isStable(v) &&
|
|
947
|
+
!bad.has(v) &&
|
|
948
|
+
majorOf(v) === line &&
|
|
949
|
+
compareVersions(v, floor) >= 0)
|
|
950
|
+
.map(([v]) => v);
|
|
951
|
+
return pickMax(ok);
|
|
952
|
+
}
|
|
953
|
+
function vulnThreat(ref, version, verdict, fixed) {
|
|
954
|
+
const what = verdict.summary ? `: ${verdict.summary.replace(/\s+/g, " ").slice(0, 100)}` : "";
|
|
955
|
+
const ids = verdict.ids.slice(0, 3).join(", ") + (verdict.ids.length > 3 ? ", …" : "");
|
|
956
|
+
return {
|
|
957
|
+
rule: "known-vulnerability",
|
|
958
|
+
severity: "block",
|
|
959
|
+
detail: `${ref.name}@${version} has known ${verdict.severity} security flaws (${ids})${what}; use ${ref.name}@${fixed}, the newest ${majorOf(version)}.x release with those fixed.`,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/** Swallow network failures: the guard is fail-open (the cooldown still applies). */
|
|
963
|
+
async function safe(what, fn) {
|
|
964
|
+
try {
|
|
965
|
+
return await fn();
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
log("WARN", "package-threats", `${what} failed, allowing: ${String(err)}`);
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function resolvedItem(r) {
|
|
973
|
+
return { ecosystem: r.ref.ecosystem, name: r.ref.name, version: r.version ?? "" };
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Named installs. Everything independent runs at once, so a clean install
|
|
977
|
+
* costs one registry round trip (+ one OSV round trip for unpinned refs whose
|
|
978
|
+
* version isn't known until the registry answers):
|
|
979
|
+
* - registry docs for every ref (full packument only for typosquat
|
|
980
|
+
* candidates; abbreviated otherwise),
|
|
981
|
+
* - OSV for exactly pinned refs, concurrently with the registry,
|
|
982
|
+
* - weekly downloads for npm typosquat candidates,
|
|
983
|
+
* - the manager version (native cooldown?) — spawned once per process.
|
|
984
|
+
*/
|
|
985
|
+
async function checkNamed(local, ctx) {
|
|
144
986
|
const threats = [];
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
987
|
+
const refs = local.refs.filter((r) => !local.flagged.has(r.name));
|
|
988
|
+
const squats = new Map(local.typosquats.map((t) => [t.ref.name, t.popular]));
|
|
989
|
+
const native = new Map();
|
|
990
|
+
for (const r of refs) {
|
|
991
|
+
if (!native.has(r.manager))
|
|
992
|
+
native.set(r.manager, nativeCooldown(r.manager, ctx));
|
|
993
|
+
}
|
|
994
|
+
const pinned = refs
|
|
995
|
+
.filter((r) => isExact(r.spec))
|
|
996
|
+
.map((r) => ({
|
|
997
|
+
ecosystem: r.ecosystem,
|
|
998
|
+
name: r.name,
|
|
999
|
+
version: (r.spec ?? "").replace(/^v/, ""),
|
|
1000
|
+
}));
|
|
1001
|
+
const earlyOsv = pinned.length > 0 ? safe("OSV lookup", () => osvMalware(pinned, ctx)) : null;
|
|
1002
|
+
const downloads = new Map(refs
|
|
1003
|
+
.filter((r) => r.ecosystem === "npm" && squats.has(r.name))
|
|
1004
|
+
.map((r) => [
|
|
1005
|
+
r.name,
|
|
1006
|
+
safe(`downloads ${r.name}`, () => getJson(`${NPM_DOWNLOADS}/${r.name}`, ctx)),
|
|
1007
|
+
]));
|
|
1008
|
+
const resolved = (await Promise.all(refs.map((r) => safe(`lookup ${r.name}`, () => resolveRef(r, squats.has(r.name), ctx))))).filter((r) => r !== null);
|
|
1009
|
+
// P5: typosquat → block only if missing, new or barely used.
|
|
1010
|
+
const squatted = new Set();
|
|
1011
|
+
for (const r of resolved) {
|
|
1012
|
+
const popular = squats.get(r.ref.name);
|
|
1013
|
+
if (!popular)
|
|
1014
|
+
continue;
|
|
1015
|
+
const reason = await safe(`typosquat ${r.ref.name}`, () => typosquatReason(r, downloads.get(r.ref.name) ?? Promise.resolve(null), ctx));
|
|
1016
|
+
if (reason) {
|
|
1017
|
+
squatted.add(r.ref.name);
|
|
148
1018
|
threats.push({
|
|
149
1019
|
rule: "typosquat",
|
|
150
|
-
severity: "
|
|
151
|
-
detail: `${
|
|
1020
|
+
severity: "block",
|
|
1021
|
+
detail: `${r.ref.name} looks like a typosquat of popular "${popular}" (${reason}); use ${popular} if that is what you meant.`,
|
|
1022
|
+
blocked: [
|
|
1023
|
+
{ ecosystem: r.ref.ecosystem, name: r.ref.name, reason: `lookalike of "${popular}"` },
|
|
1024
|
+
],
|
|
152
1025
|
});
|
|
153
1026
|
}
|
|
154
1027
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
1028
|
+
const live = resolved.filter((r) => !r.missing && r.version !== null && !squatted.has(r.ref.name));
|
|
1029
|
+
// Malware: local feed with the resolved version, then OSV with exact versions.
|
|
1030
|
+
const pinnedKeys = new Set(pinned.map(osvKey));
|
|
1031
|
+
const late = live.map(resolvedItem).filter((i) => !pinnedKeys.has(osvKey(i)));
|
|
1032
|
+
const [early, rest] = await Promise.all([
|
|
1033
|
+
earlyOsv,
|
|
1034
|
+
late.length > 0 ? safe("OSV lookup", () => osvMalware(late, ctx)) : null,
|
|
1035
|
+
]);
|
|
1036
|
+
const osv = new Map([...(early ?? []), ...(rest ?? [])]);
|
|
1037
|
+
const malware = new Set();
|
|
1038
|
+
for (const r of live) {
|
|
1039
|
+
const version = r.version ?? "";
|
|
1040
|
+
const ids = [
|
|
1041
|
+
...new Set([
|
|
1042
|
+
...feedMatches(ctx.feed, r.ref.ecosystem, r.ref.name, version),
|
|
1043
|
+
...(osv.get(osvKey(resolvedItem(r))) ?? []),
|
|
1044
|
+
]),
|
|
1045
|
+
];
|
|
1046
|
+
if (ids.length === 0)
|
|
1047
|
+
continue;
|
|
1048
|
+
malware.add(r.ref.name);
|
|
1049
|
+
const times = (await safe("publish times", () => publishTimes(r, ctx))) ?? new Map();
|
|
1050
|
+
const cutoff = ctx.now - Math.max(ctx.days, 0) * DAY_MS;
|
|
1051
|
+
const bad = feedFlagged(ctx, r.ref, times.keys()).add(version);
|
|
1052
|
+
const suggestion = lastOlderThan(times, cutoff, bad, version);
|
|
1053
|
+
threats.push(malwareThreat(r.ref, version, ids, suggestion, ctx.days));
|
|
1054
|
+
}
|
|
1055
|
+
// Serious known flaws: only versions the batch lookup already saw
|
|
1056
|
+
// advisories for get the one detailed request; all of them in parallel.
|
|
1057
|
+
const vulnChecks = await Promise.all(live
|
|
1058
|
+
.filter((r) => !malware.has(r.ref.name) && osvFlawed(resolvedItem(r), ctx))
|
|
1059
|
+
.map(async (r) => {
|
|
1060
|
+
const version = r.version ?? "";
|
|
1061
|
+
const verdict = await safe(`advisories ${r.ref.name}`, () => seriousFlaws(resolvedItem(r), ctx));
|
|
1062
|
+
if (!verdict)
|
|
1063
|
+
return null;
|
|
1064
|
+
const times = await safe("publish times", () => publishTimes(r, ctx));
|
|
1065
|
+
if (!times)
|
|
1066
|
+
return null;
|
|
1067
|
+
const cutoff = ctx.now - Math.max(ctx.days, 0) * DAY_MS;
|
|
1068
|
+
const bad = feedFlagged(ctx, r.ref, times.keys()).add(version);
|
|
1069
|
+
const fixed = fixedOnSameLine(version, verdict, times, cutoff, bad);
|
|
1070
|
+
return fixed
|
|
1071
|
+
? { name: r.ref.name, threat: vulnThreat(r.ref, version, verdict, fixed) }
|
|
1072
|
+
: null;
|
|
1073
|
+
}));
|
|
1074
|
+
const flawed = new Set();
|
|
1075
|
+
for (const v of vulnChecks) {
|
|
1076
|
+
if (!v)
|
|
1077
|
+
continue;
|
|
1078
|
+
flawed.add(v.name);
|
|
1079
|
+
threats.push(v.threat);
|
|
1080
|
+
}
|
|
1081
|
+
for (const r of live) {
|
|
1082
|
+
if (malware.has(r.ref.name) || flawed.has(r.ref.name))
|
|
1083
|
+
continue;
|
|
1084
|
+
const version = r.version ?? "";
|
|
1085
|
+
// P4: provenance downgrade (npm).
|
|
1086
|
+
if (r.npm?.versions) {
|
|
1087
|
+
const all = Object.keys(r.npm.versions);
|
|
1088
|
+
const prev = pickMax(all.filter((v) => isStable(v) && compareVersions(v, version) < 0));
|
|
1089
|
+
const had = prev ? r.npm.versions[prev]?.dist?.attestations : undefined;
|
|
1090
|
+
const has = r.npm.versions[version]?.dist?.attestations;
|
|
1091
|
+
if (prev && had && !has && r.npm.versions[version]) {
|
|
1092
|
+
threats.push({
|
|
1093
|
+
rule: "provenance-downgrade",
|
|
1094
|
+
severity: "block",
|
|
1095
|
+
detail: `${r.ref.name}@${version} was published without the provenance attestation ${r.ref.name}@${prev} had (a hijacked-release signal); use ${r.ref.name}@${prev}.`,
|
|
1096
|
+
blocked: [
|
|
1097
|
+
{
|
|
1098
|
+
ecosystem: r.ref.ecosystem,
|
|
1099
|
+
name: r.ref.name,
|
|
1100
|
+
version,
|
|
1101
|
+
reason: "published without its usual signature (likely hijacked)",
|
|
1102
|
+
},
|
|
1103
|
+
],
|
|
1104
|
+
});
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// P3: own release-age check only when the manager can't enforce the cooldown.
|
|
1109
|
+
if (ctx.days > 0 && !(await (native.get(r.ref.manager) ?? Promise.resolve(true)))) {
|
|
1110
|
+
const times = (await safe("publish times", () => publishTimes(r, ctx))) ?? new Map();
|
|
1111
|
+
const published = times.get(version);
|
|
1112
|
+
const cutoff = ctx.now - ctx.days * DAY_MS;
|
|
1113
|
+
if (published !== undefined && published > cutoff) {
|
|
1114
|
+
const older = lastOlderThan(times, cutoff, feedFlagged(ctx, r.ref, times.keys()), version);
|
|
1115
|
+
const hours = Math.max(1, Math.round((ctx.now - published) / 3_600_000));
|
|
1116
|
+
const instead = older
|
|
1117
|
+
? `use ${r.ref.name}@${older}, the last version older than ${ctx.days} days`
|
|
1118
|
+
: `wait until it is ${ctx.days} days old`;
|
|
1119
|
+
threats.push({
|
|
1120
|
+
rule: "release-age",
|
|
1121
|
+
severity: "block",
|
|
1122
|
+
detail: `${r.ref.name}@${version} was published ${hours}h ago (cooldown ${ctx.days} days); ${instead}.`,
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
161
1126
|
}
|
|
162
1127
|
return threats;
|
|
163
1128
|
}
|
|
164
|
-
|
|
1129
|
+
async function nativeCooldown(manager, ctx) {
|
|
1130
|
+
const m = manager === "npx" ? "npm" : manager === "uvx" ? "uv" : manager;
|
|
1131
|
+
if (m === "uv")
|
|
1132
|
+
return true;
|
|
1133
|
+
return hasNativeCooldown(m, await ctx.managerVersion(m));
|
|
1134
|
+
}
|
|
1135
|
+
async function typosquatReason(r, downloads, ctx) {
|
|
1136
|
+
if (r.missing)
|
|
1137
|
+
return ctx.privateSource(r.ref.ecosystem) ? null : "it does not exist on the registry";
|
|
1138
|
+
const times = await publishTimes(r, ctx);
|
|
1139
|
+
const first = Math.min(...times.values());
|
|
1140
|
+
if (Number.isFinite(first) && ctx.now - first < TYPOSQUAT_NEW_DAYS * DAY_MS) {
|
|
1141
|
+
return `first published ${Math.max(0, Math.floor((ctx.now - first) / DAY_MS))} days ago`;
|
|
1142
|
+
}
|
|
1143
|
+
const dl = (await downloads);
|
|
1144
|
+
if (dl &&
|
|
1145
|
+
!(dl instanceof NotFound) &&
|
|
1146
|
+
typeof dl.downloads === "number" &&
|
|
1147
|
+
dl.downloads < TYPOSQUAT_MIN_WEEKLY) {
|
|
1148
|
+
return `only ${dl.downloads} downloads last week`;
|
|
1149
|
+
}
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
async function checkLockfile(lock, cwd, ctx) {
|
|
1153
|
+
const file = path.resolve(cwd, lock.file);
|
|
1154
|
+
let text;
|
|
1155
|
+
try {
|
|
1156
|
+
text = await fsp.readFile(file, "utf8");
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
return [];
|
|
1160
|
+
}
|
|
1161
|
+
const hash = await hashInSlices(text);
|
|
1162
|
+
const cacheKey = `${lock.ecosystem}:${hash}:${Object.keys(ctx.feed.entries).length}`;
|
|
1163
|
+
const remembered = lockfileCache.get(cacheKey) ?? ctx.cache.lock(cacheKey);
|
|
1164
|
+
// A lockfile verdict is re-asked of OSV at most once a day, so a package
|
|
1165
|
+
// flagged after the last check is still caught even if the feed is unchanged.
|
|
1166
|
+
const cached = remembered && ctx.now - remembered.at < DAY_MS ? remembered : undefined;
|
|
1167
|
+
if (cached) {
|
|
1168
|
+
lockfileCache.set(cacheKey, cached);
|
|
1169
|
+
return cached.threats;
|
|
1170
|
+
}
|
|
1171
|
+
const pkgs = await parseLockfileAsync(file, text);
|
|
1172
|
+
const hits = new Map();
|
|
1173
|
+
for (const p of pkgs) {
|
|
1174
|
+
const ids = feedMatches(ctx.feed, lock.ecosystem, p.name, p.version);
|
|
1175
|
+
if (ids.length > 0)
|
|
1176
|
+
hits.set(`${p.name}@${p.version}`, ids);
|
|
1177
|
+
}
|
|
1178
|
+
const osv = await safe("OSV lockfile lookup", () => osvMalware(pkgs.map((p) => ({ ecosystem: lock.ecosystem, name: p.name, version: p.version })), ctx));
|
|
1179
|
+
for (const [key, ids] of osv ?? []) {
|
|
1180
|
+
const short = key.slice(key.indexOf(":") + 1);
|
|
1181
|
+
hits.set(short, [...new Set([...(hits.get(short) ?? []), ...ids])]);
|
|
1182
|
+
}
|
|
1183
|
+
const threats = hits.size === 0
|
|
1184
|
+
? []
|
|
1185
|
+
: [
|
|
1186
|
+
{
|
|
1187
|
+
rule: "malicious-package",
|
|
1188
|
+
severity: "block",
|
|
1189
|
+
packages: [...hits].map(([k, ids]) => `${k} (${ids.join(", ")})`),
|
|
1190
|
+
blocked: [...hits].map(([k, ids]) => {
|
|
1191
|
+
const at = k.lastIndexOf("@");
|
|
1192
|
+
return {
|
|
1193
|
+
ecosystem: lock.ecosystem,
|
|
1194
|
+
name: k.slice(0, at),
|
|
1195
|
+
version: k.slice(at + 1),
|
|
1196
|
+
reason: `known malware (${ids.join(", ")})`,
|
|
1197
|
+
};
|
|
1198
|
+
}),
|
|
1199
|
+
detail: `${path.basename(file)} pins malware: ${[...hits]
|
|
1200
|
+
.slice(0, 5)
|
|
1201
|
+
.map(([k, ids]) => `${k} (${ids.join(", ")})`)
|
|
1202
|
+
.join(", ")}${hits.size > 5 ? ` and ${hits.size - 5} more` : ""}; update those packages to a clean version and regenerate the lockfile.`,
|
|
1203
|
+
},
|
|
1204
|
+
];
|
|
1205
|
+
// Only cache a complete answer (OSV reached); offline answers are retried.
|
|
1206
|
+
if (osv !== null) {
|
|
1207
|
+
const verdict = { at: ctx.now, threats };
|
|
1208
|
+
lockfileCache.set(cacheKey, verdict);
|
|
1209
|
+
ctx.cache.setLock(cacheKey, verdict);
|
|
1210
|
+
}
|
|
1211
|
+
return threats;
|
|
1212
|
+
}
|
|
1213
|
+
/** Rejects with the deadline so no network wait can stack past it (fail-open). */
|
|
1214
|
+
function deadline(signal) {
|
|
1215
|
+
return new Promise((_, reject) => {
|
|
1216
|
+
if (signal.aborted)
|
|
1217
|
+
reject(new Error("package check deadline"));
|
|
1218
|
+
signal.addEventListener("abort", () => reject(new Error("package check deadline")), {
|
|
1219
|
+
once: true,
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
/** Context for one check, under ONE deadline shared by every lookup. */
|
|
1224
|
+
function makeCtx(options, feed, command = "") {
|
|
1225
|
+
const timeout = AbortSignal.timeout(TIMEOUT_MS);
|
|
1226
|
+
const cache = verdictCache(options.cacheDir !== undefined
|
|
1227
|
+
? options.cacheDir
|
|
1228
|
+
: process.env.VITEST
|
|
1229
|
+
? null // tests never write to the real GG dir
|
|
1230
|
+
: getAppPaths().agentDir);
|
|
1231
|
+
return {
|
|
1232
|
+
fetch: options.fetch ?? fetch,
|
|
1233
|
+
signal: options.signal ? AbortSignal.any([timeout, options.signal]) : timeout,
|
|
1234
|
+
cancelled: () => options.signal?.aborted === true,
|
|
1235
|
+
reached: { value: false },
|
|
1236
|
+
now: (options.now ?? Date.now)(),
|
|
1237
|
+
days: options.cooldownDays ?? 2,
|
|
1238
|
+
feed,
|
|
1239
|
+
cache,
|
|
1240
|
+
managerVersion: options.managerVersion ?? ((m) => managerVersion(m, options.cwd)),
|
|
1241
|
+
privateSource: (eco) => eco === "npm"
|
|
1242
|
+
? usesCustomNpmRegistry(command, options.cwd ?? process.cwd(), options.env)
|
|
1243
|
+
: usesCustomPypiIndex(command, options.env),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
/** Lockfile names checked for already-installed packages, with their ecosystem. */
|
|
1247
|
+
export const PROJECT_LOCKFILES = [
|
|
1248
|
+
{ ecosystem: "npm", manager: "npm", file: "package-lock.json" },
|
|
1249
|
+
{ ecosystem: "npm", manager: "pnpm", file: "pnpm-lock.yaml" },
|
|
1250
|
+
{ ecosystem: "npm", manager: "yarn", file: "yarn.lock" },
|
|
1251
|
+
{ ecosystem: "npm", manager: "bun", file: "bun.lock" },
|
|
1252
|
+
{ ecosystem: "PyPI", manager: "uv", file: "uv.lock", resolves: true },
|
|
1253
|
+
{ ecosystem: "PyPI", manager: "pip", file: "requirements.txt" },
|
|
1254
|
+
];
|
|
1255
|
+
/**
|
|
1256
|
+
* Malware check of one existing lockfile (already-installed packages): the
|
|
1257
|
+
* local feed plus one batched OSV lookup, cached by content hash and re-asked
|
|
1258
|
+
* at most once a day. No release-age check. Fail-open like every other check.
|
|
1259
|
+
*/
|
|
1260
|
+
export async function checkLockfileMalware(lock, cwd, options = {}) {
|
|
1261
|
+
const ctx = makeCtx(options, options.feed ?? loadThreatFeed());
|
|
1262
|
+
const work = (async () => {
|
|
1263
|
+
await ctx.cache.load();
|
|
1264
|
+
return checkLockfile({ ...lock, resolves: true }, cwd, ctx);
|
|
1265
|
+
})();
|
|
1266
|
+
const out = await Promise.race([work, deadline(ctx.signal)]).catch((err) => {
|
|
1267
|
+
log("WARN", "package-threats", `lockfile check cut short: ${String(err)}`);
|
|
1268
|
+
noteDeadline(ctx);
|
|
1269
|
+
return [];
|
|
1270
|
+
});
|
|
1271
|
+
ctx.cache.save(ctx.now);
|
|
1272
|
+
return out;
|
|
1273
|
+
}
|
|
1274
|
+
/** Full check for one bash command. Returns blocks only; [] when clean or not an install. */
|
|
1275
|
+
export async function checkPackageInstall(command, options = {}) {
|
|
1276
|
+
const local = localPackageCheck(command, () => options.feed ?? loadThreatFeed());
|
|
1277
|
+
if (local.refs.length === 0 && local.lockfiles.length === 0)
|
|
1278
|
+
return [];
|
|
1279
|
+
const ctx = makeCtx(options, local.feed, command);
|
|
1280
|
+
const cache = ctx.cache;
|
|
1281
|
+
const threats = [...local.threats];
|
|
1282
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1283
|
+
const work = (async () => {
|
|
1284
|
+
await cache.load();
|
|
1285
|
+
const parts = await Promise.all([
|
|
1286
|
+
local.refs.length > 0 ? checkNamed(local, ctx) : [],
|
|
1287
|
+
...local.lockfiles.map((lock) => checkLockfile(lock, cwd, ctx)),
|
|
1288
|
+
]);
|
|
1289
|
+
return parts.flat();
|
|
1290
|
+
})();
|
|
1291
|
+
const out = await Promise.race([work, deadline(ctx.signal)]).catch((err) => {
|
|
1292
|
+
log("WARN", "package-threats", `check cut short, allowing: ${String(err)}`);
|
|
1293
|
+
noteDeadline(ctx);
|
|
1294
|
+
return [];
|
|
1295
|
+
});
|
|
1296
|
+
cache.save(ctx.now);
|
|
1297
|
+
return [...threats, ...out];
|
|
1298
|
+
}
|
|
1299
|
+
/** One plain line for the agent, or null when nothing blocks. */
|
|
1300
|
+
export function packageBlockMessage(threats) {
|
|
1301
|
+
const blocks = threats.filter((t) => t.severity === "block");
|
|
1302
|
+
if (blocks.length === 0)
|
|
1303
|
+
return null;
|
|
1304
|
+
return `Blocked install (${blocks[0]?.rule}): ${blocks.map((t) => t.detail).join(" ")} ${SAFETY_BLOCK_NOTE}`;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Check an MCP stdio launch (`npx -y pkg`, `uvx pkg`) before spawning.
|
|
1308
|
+
* Returns the block line, or null to allow.
|
|
1309
|
+
*/
|
|
1310
|
+
export async function checkMcpLaunch(command, args, options = {}) {
|
|
1311
|
+
const base = path
|
|
1312
|
+
.basename(command)
|
|
1313
|
+
.toLowerCase()
|
|
1314
|
+
.replace(/\.(cmd|exe)$/, "");
|
|
1315
|
+
if (!["npx", "uvx", "bunx", "pnpm", "npm", "uv"].includes(base))
|
|
1316
|
+
return null;
|
|
1317
|
+
const line = [base, ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
|
|
1318
|
+
// MCP launches get no bun flag, so bunx gets the own age check.
|
|
1319
|
+
const versions = options.managerVersion ?? ((m) => managerVersion(m));
|
|
1320
|
+
const message = packageBlockMessage(await checkPackageInstall(line, {
|
|
1321
|
+
...options,
|
|
1322
|
+
managerVersion: (m) => (m === "bun" ? Promise.resolve(null) : versions(m)),
|
|
1323
|
+
}));
|
|
1324
|
+
// Shown to the user on the MCP settings row, not to the agent.
|
|
1325
|
+
return message?.replace(` ${SAFETY_BLOCK_NOTE}`, "") ?? null;
|
|
1326
|
+
}
|
|
1327
|
+
/** Wait for pending verdict-cache writes (tests). */
|
|
1328
|
+
export async function flushPackageVerdicts() {
|
|
1329
|
+
await Promise.all([...verdictCaches.values()].map((c) => c.flush()));
|
|
1330
|
+
}
|
|
1331
|
+
/** Test hook: forget cached answers (memory only; the disk file is left alone). */
|
|
165
1332
|
export function clearPackageThreatCache() {
|
|
166
1333
|
osvCache.clear();
|
|
1334
|
+
vulnCache.clear();
|
|
1335
|
+
metaCache.clear();
|
|
1336
|
+
lockfileCache.clear();
|
|
1337
|
+
verdictCaches.clear();
|
|
1338
|
+
downHosts.clear();
|
|
167
1339
|
}
|
|
168
1340
|
//# sourceMappingURL=package-threats.js.map
|