Stops Claude from installing hallucinated or typosquatted packages: every npm, pip, uv, poetry, cargo and gem install is checked against its registry first…

No more hallucinated packages. Every
npm,pip,uv,poetry,cargoandgeminstall Claude runs is checked against its registry first: names that don't exist are blocked, and brand-new or lookalike packages ask you.
● Bash(npm install react-hooks-super-utils)
⎿ Error: slopsquat-guard blocked this install: react-hooks-super-utils (npm):
no package named "react-hooks-super-utils" exists on npm. The name may be
hallucinated, and attackers register hallucinated names ("slopsquatting").
╭─ slopsquat ──────────────────────────────────────────────────╮
│ slopsquat-guard: these packages look risky: │
│ • reqests (PyPI): it was first published 2 days ago; it has │
│ only 4 downloads a week; its name is one letter away from │
│ the popular "requests" │
│ │
│ Install anyway? │
│ ❯ 1. Install anyway │
│ 2. Block it │
╰───────────────────────────────────────────────────────────────╯
Models sometimes invent plausible package names, and attackers now register those names with malware inside ("slopsquatting"). Typosquats like reqests and lodahs work the same way. slopsquat-guard checks the registry before the install command runs.
minAgeDays ago, fewer than minWeeklyDownloads a week, or one letter away from a popular package (swap, drop or add) while not popular itself. A headless run refuses.npm i/install/add, pnpm add, yarn add, bun add, npx/bunx/pnpm dlx (which download and run straight away), pip/pip3/python -m pip install, uv add, uv pip install, uvx, uv tool install, poetry/pdm/rye/hatch add, pipenv install, pipx install/run, cargo add/install, gem install, bundle add. Versions, extras, scopes and aliases (x@npm:real) are handled; chains like cd web && npm i axios are handled.npm install, -r requirements.txt, -e ., local paths, tarballs and wheels, git+…, github:, file:, workspace:. npx tsc is skipped when the project already has the binary. A command pointing at its own registry (--registry, -i, --index-url) is skipped too, since the guard can't vouch for a private index.HEAD plus a tiny downloads lookup (no multi-megabyte metadata). Verdicts are cached for 24 h (1 h for "missing").timeoutMs, the install runs, and one toast says it went unchecked./slopsquat <npm|pypi|crates|gem> <name> checks a package by hand, for example OK: react exists on npm (222,767,611 downloads a week)./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install slopsquat-guard@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
| Option | Default | What it does |
|---|---|---|
minAgeDays | 14 | A package first published fewer days ago asks first. |
minWeeklyDownloads | 50 | A package with fewer weekly downloads asks first. RubyGems is estimated from lifetime downloads. |
allowList | empty | Comma-separated names that are never checked: your private and internal packages. |
timeoutMs | 5000 | How long to wait for a registry before letting the install run unchecked. |
| Event / API | Why |
|---|---|
tool.call on Bash | Parses the command for installs, checks each package, returns { deny } for missing ones, asks with $.ui.ask for risky ones, else next(e) |
$.http.fetch | registry.npmjs.org and api.npmjs.org, pypi.org and pypistats.org, crates.io, rubygems.org |
$.store | The verdict cache (newest 400, one week) and the all-time blocked count |
$.fs.exists | Skips npx <bin> when node_modules/.bin/<bin> exists |
command.run | /slopsquat |
The parser (hooks/parse.ts) and the verdict logic (hooks/verdict.ts) are pure TypeScript with no $.
claude plugin test mods/safety/slopsquat-guard # 88 tests
In a live headless run, Claude was asked to npm install this-package-should-not-exist-acm-xyz. Against the real npm registry the guard refused it with the message above, and the model stopped.
package.json, requirements.txt, lockfiles or transitively aren't checked.hooks/register.ts 203 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { parseInstalls, type Ecosystem, type Install } from './parse'
4import { isPopular, judge, REGISTRY_NAMES, urlsFor, weeklyFrom, type Answer, type Limits, type Verdict } from './verdict'
5
6const INSTALL = 'Install anyway'
7const STOP = 'Block it'
8const HEADERS = { 'User-Agent': 'slopsquat-guard (https://github.com/Singh-AP/awesome-claude-mods)', Accept: 'application/json' }
9const HOUR = 60 * 60 * 1000
10const MAX_CHECKS = 12
11
12type Cached = { verdict: Verdict; at: number }
13type Cache = Record<string, Cached>
14
15// Registries already reported unreachable this session, so the toast comes once.
16const unreachable = new Set<string>()
17let blocked = 0
18
19/** One request, given up after `ms`: undefined for no answer, whatever the reason. */
20async function ask($: EngineInterface, url: string, method: 'GET' | 'HEAD', ms: number): Promise<Answer> {
21 try {
22 const answer = await Promise.race([
23 $.http.fetch(url, { method, headers: HEADERS }),
24 $.clock.sleep(ms).then(() => undefined),
25 ])
26 return answer === undefined ? undefined : { status: answer.status, text: answer.text }
27 } catch {
28 return undefined
29 }
30}
31
32/** Asks the package's registry about it: existence and downloads first, metadata only when they leave doubt. */
33async function inspect($: EngineInterface, ecosystem: Ecosystem, name: string, limits: Limits, ms: number): Promise<Verdict> {
34 const urls = urlsFor(ecosystem, name)
35 const now = await $.clock.now()
36 if (urls.downloads === undefined) {
37 const meta = await ask($, urls.meta, 'GET', ms)
38 return judge(ecosystem, name, { exists: meta, meta }, now, limits)
39 }
40 // npm's full metadata for a popular package runs to megabytes: HEAD it first.
41 const [exists, downloads] = await Promise.all([ask($, urls.meta, 'HEAD', ms), ask($, urls.downloads, 'GET', ms)])
42 if (exists === undefined || exists.status !== 200 || isPopular(weeklyFrom(ecosystem, downloads))) {
43 return judge(ecosystem, name, { exists, downloads }, now, limits)
44 }
45 const meta = await ask($, urls.meta, 'GET', ms)
46 return judge(ecosystem, name, { exists, meta, downloads }, now, limits)
47}
48
49/** Verdicts for every install, from the cache where it is fresh. */
50async function verdicts($: EngineInterface, installs: Install[], limits: Limits, ms: number): Promise<Verdict[]> {
51 const cache = ((await $.store.get('cache')) ?? {}) as Cache
52 const now = await $.clock.now()
53 const fresh = (hit: Cached | undefined) =>
54 hit !== undefined && now - hit.at < (hit.verdict.status === 'missing' ? HOUR : 24 * HOUR)
55
56 let isChanged = false
57 const out = await Promise.all(
58 installs.map(async install => {
59 const key = `${install.ecosystem}:${install.name}`
60 const hit = cache[key]
61 if (hit !== undefined && fresh(hit)) return hit.verdict
62 const verdict = await inspect($, install.ecosystem, install.name, limits, ms)
63 if (verdict.status !== 'unknown') {
64 cache[key] = { verdict, at: now }
65 isChanged = true
66 }
67 return verdict
68 }),
69 )
70 if (isChanged) {
71 // Keep the newest 400 verdicts from the last week.
72 const kept = Object.entries(cache)
73 .filter(([, v]) => now - v.at < 7 * 24 * HOUR)
74 .sort((a, b) => b[1].at - a[1].at)
75 .slice(0, 400)
76 await $.store.set('cache', Object.fromEntries(kept))
77 }
78 return out
79}
80
81async function refuse($: EngineInterface, text: string) {
82 blocked += 1
83 const total = Number((await $.store.get('blockedTotal')) ?? 0) + 1
84 await $.store.set('blockedTotal', total)
85 return { deny: text }
86}
87
88const describe = (install: Install, verdict: Verdict) =>
89 `${install.name} (${REGISTRY_NAMES[install.ecosystem]}): ${verdict.reasons.join('; ')}`
90
91const ECOSYSTEMS: Record<string, Ecosystem> = {
92 npm: 'npm', node: 'npm', js: 'npm', yarn: 'npm', pnpm: 'npm', bun: 'npm',
93 pypi: 'pypi', pip: 'pypi', python: 'pypi', py: 'pypi', uv: 'pypi',
94 crates: 'crates', cargo: 'crates', rust: 'crates', crate: 'crates',
95 gem: 'rubygems', gems: 'rubygems', rubygems: 'rubygems', ruby: 'rubygems',
96}
97
98export const register: Register = (on, options) => {
99 const limits: Limits = {
100 minAgeDays: Number(options.minAgeDays ?? 14),
101 minWeeklyDownloads: Number(options.minWeeklyDownloads ?? 50),
102 }
103 const ms = Number(options.timeoutMs ?? 5000)
104 const allowed = new Set(
105 String(options.allowList ?? '')
106 .split(/[\s,]+/)
107 .map(name => name.trim().toLowerCase())
108 .filter(name => name !== ''),
109 )
110
111 on('session.start', async ($, e, next) => {
112 await $.command.register({
113 name: 'slopsquat',
114 description: 'Check a package before installing it: /slopsquat <npm|pypi|crates|gem> <name>',
115 argumentHint: '<ecosystem> <name>',
116 })
117 return next(e)
118 })
119
120 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
121 const { installs } = parseInstalls(e.command)
122 const todo: Install[] = []
123 for (const install of installs) {
124 if (allowed.has(install.name)) continue
125 // `npx tsc` runs the project's own binary when it has one.
126 const bin = install.name.replace(/^@[^/]+\//, '')
127 if (install.isRun && install.ecosystem === 'npm' && (await $.fs.exists(`node_modules/.bin/${bin}`))) continue
128 todo.push(install)
129 }
130 if (todo.length === 0) return next(e)
131
132 const checked = todo.slice(0, MAX_CHECKS)
133 const results = await verdicts($, checked, limits, ms)
134 const pairs = checked.map((install, i) => ({ install, verdict: results[i]! }))
135
136 const missing = pairs.filter(p => p.verdict.status === 'missing')
137 if (missing.length > 0) {
138 const names = missing.map(p => `"${p.install.name}" (${REGISTRY_NAMES[p.install.ecosystem]})`).join(', ')
139 $.ui.toast(`slopsquat-guard blocked ${names}: no such package`)
140 return refuse(
141 $,
142 `slopsquat-guard blocked this install: ${missing.map(p => describe(p.install, p.verdict)).join('; ')}. ` +
143 'The name may be hallucinated, and attackers register hallucinated names ("slopsquatting"). ' +
144 'Do not guess another spelling: find the real package name in the project docs or the registry, or ask the user.',
145 )
146 }
147
148 for (const { install, verdict } of pairs) {
149 if (verdict.status !== 'unknown') continue
150 const registry = REGISTRY_NAMES[install.ecosystem]
151 if (unreachable.has(registry)) continue
152 unreachable.add(registry)
153 $.ui.toast(`slopsquat-guard could not reach ${registry}, so ${install.name} was not checked`)
154 }
155
156 const odd = pairs.filter(p => p.verdict.status === 'suspicious')
157 if (odd.length === 0) return next(e)
158
159 const lines = odd.map(p => ` • ${describe(p.install, p.verdict)}`).join('\n')
160 let answer = STOP
161 try {
162 answer = await $.ui.ask(`slopsquat-guard: these packages look risky:\n${lines}\n\nInstall anyway?`, {
163 header: 'slopsquat',
164 options: [INSTALL, STOP],
165 })
166 } catch {
167 // Nobody to ask (a -p run) or the dialog was dismissed: stay safe.
168 }
169 if (answer === INSTALL) return next(e)
170 return refuse(
171 $,
172 `slopsquat-guard blocked this install after a risk check: ${odd.map(p => describe(p.install, p.verdict)).join('; ')}. ` +
173 'Tell the user which package you meant and why, and let them decide.',
174 )
175 })
176
177 on('command.run', { command: 'slopsquat' }, async ($, e) => {
178 const [kind, name] = e.args.trim().split(/\s+/)
179 const ecosystem = kind === undefined ? undefined : ECOSYSTEMS[kind.toLowerCase()]
180 if (ecosystem === undefined || name === undefined || name === '') {
181 const total = Number((await $.store.get('blockedTotal')) ?? 0)
182 return {
183 text:
184 'Usage: /slopsquat <npm|pypi|crates|gem> <name>\n' +
185 `slopsquat-guard has blocked ${blocked} install${blocked === 1 ? '' : 's'} this session, ${total} all time.`,
186 }
187 }
188 const verdict = await inspect($, ecosystem, name.toLowerCase(), limits, ms)
189 const registry = REGISTRY_NAMES[ecosystem]
190 const facts = [
191 verdict.createdAt === undefined ? undefined : `first published ${verdict.createdAt.slice(0, 10)}`,
192 verdict.weeklyDownloads === undefined ? undefined : `${verdict.weeklyDownloads.toLocaleString('en-US')} downloads a week`,
193 ].filter(Boolean).join(', ')
194 const head = {
195 ok: `OK: ${name} exists on ${registry}`,
196 suspicious: `RISKY: ${name} on ${registry}: ${verdict.reasons.join('; ')}`,
197 missing: `MISSING: ${verdict.reasons.join('; ')}`,
198 unknown: `UNKNOWN: ${verdict.reasons.join('; ')}`,
199 }[verdict.status]
200 return { text: facts === '' ? head : `${head} (${facts})` }
201 })
202}
203hooks/parse.ts 309 lines1// Pure parsing of package-install commands: no `$`, so tests can import it.
2
3export type Ecosystem = 'npm' | 'pypi' | 'crates' | 'rubygems'
4
5export type Install = {
6 ecosystem: Ecosystem
7 /** The package's registry name, versions and extras stripped. */
8 name: string
9 /** As written in the command. */
10 spec: string
11 /** Run straight away (`npx`, `uvx`, `pipx run`) rather than installed. */
12 isRun: boolean
13}
14
15export type Parsed = {
16 installs: Install[]
17 /** The command names its own registry or index, which this guard cannot vouch for. */
18 hasCustomRegistry: boolean
19}
20
21/** Splits one shell segment into words, honouring quotes and backslashes. */
22export function words(segment: string): string[] {
23 const out: string[] = []
24 let current = ''
25 let quote: '"' | "'" | null = null
26 let hasWord = false
27 for (let i = 0; i < segment.length; i++) {
28 const ch = segment[i] ?? ''
29 if (quote !== null) {
30 if (ch === quote) quote = null
31 else if (ch === '\\' && quote === '"' && i + 1 < segment.length) current += segment[++i]
32 else current += ch
33 continue
34 }
35 if (ch === '"' || ch === "'") {
36 quote = ch
37 hasWord = true
38 } else if (ch === '\\' && i + 1 < segment.length) {
39 current += segment[++i]
40 hasWord = true
41 } else if (/\s/.test(ch)) {
42 if (hasWord) out.push(current)
43 current = ''
44 hasWord = false
45 } else {
46 current += ch
47 hasWord = true
48 }
49 }
50 if (hasWord) out.push(current)
51 return out
52}
53
54/** Splits a command line at `;`, `&&`, `||`, `|`, `&` and newlines outside quotes. */
55export function segments(command: string): string[] {
56 const parts: string[] = []
57 let current = ''
58 let quote: '"' | "'" | null = null
59 for (let i = 0; i < command.length; i++) {
60 const ch = command[i] ?? ''
61 if (quote !== null) {
62 if (ch === quote) quote = null
63 current += ch
64 continue
65 }
66 if (ch === '"' || ch === "'") {
67 quote = ch
68 current += ch
69 continue
70 }
71 if (ch === '\\') {
72 current += ch + (command[i + 1] ?? '')
73 i++
74 continue
75 }
76 const isAmp = ch === '&' && command[i + 1] !== '>' && command[i - 1] !== '>'
77 if (ch === ';' || ch === '\n' || ch === '|' || isAmp) {
78 parts.push(current)
79 current = ''
80 if (command[i + 1] === ch) i++
81 continue
82 }
83 current += ch
84 }
85 parts.push(current)
86 return parts.map(p => p.trim()).filter(p => p !== '')
87}
88
89const ENV_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/
90const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time'])
91
92/** Drops `VAR=value` words and wrappers such as `sudo`. */
93function unwrap(argv: string[]): string[] {
94 let rest = argv
95 while (rest[0] !== undefined && (ENV_WORD.test(rest[0]) || WRAPPERS.has(rest[0]))) {
96 rest = rest.slice(1)
97 while (rest[0] !== undefined && rest[0].startsWith('-') && !ENV_WORD.test(rest[0])) rest = rest.slice(1)
98 }
99 return rest
100}
101
102const base = (word: string | undefined) => (word ?? '').split('/').pop() ?? ''
103
104/**
105 * The operands of an install subcommand: every word that is not a flag or a
106 * flag's value. `takesValue` names the flags whose next word is their value.
107 */
108function operands(args: string[], takesValue: Set<string>): string[] {
109 const out: string[] = []
110 for (let i = 0; i < args.length; i++) {
111 const arg = args[i] ?? ''
112 if (arg === '--') {
113 out.push(...args.slice(i + 1))
114 break
115 }
116 if (arg.startsWith('-')) {
117 if (!arg.includes('=') && takesValue.has(arg)) i++
118 continue
119 }
120 out.push(arg)
121 }
122 return out
123}
124
125const hasFlag = (args: string[], ...flags: string[]) => args.some(a => flags.some(f => a === f || a.startsWith(`${f}=`)))
126
127// ---------------------------------------------------------------- npm
128
129const NPM_VALUE_FLAGS = new Set(['-w', '--workspace', '--prefix', '--tag', '--omit', '--include', '--cache', '--filter', '-C', '--dir', '--save-prefix', '--userconfig', '--package', '-p', '--registry', '--scope', '--cwd'])
130const NPM_NAME = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i
131
132/** `@scope/name@^1.2` → `@scope/name`; undefined for a path, URL, alias to one, or tag-only spec. */
133export function npmName(spec: string): string | undefined {
134 // Paths, URLs and protocol specs (`file:`, `git+`, `github:`, `workspace:`).
135 if (/^(\.{0,2}\/|~\/|[a-z]+:|git\+)/i.test(spec)) return undefined
136 if (/\.(tgz|tar\.gz|tar)$/i.test(spec)) return undefined
137 // An alias names the real package: `alias@npm:real@1`.
138 const alias = spec.match(/^[^@]+@npm:(.+)$/) ?? spec.match(/^(@[^/]+\/[^@]+)@npm:(.+)$/)
139 if (alias) return npmName(alias.at(-1) ?? '')
140 let name = spec
141 if (name.startsWith('@')) {
142 const at = name.indexOf('@', 1)
143 if (at > 0) name = name.slice(0, at)
144 } else {
145 const at = name.indexOf('@')
146 if (at === 0) return undefined
147 if (at > 0) name = name.slice(0, at)
148 // `user/repo` is GitHub shorthand, not a registry package.
149 if (name.includes('/')) return undefined
150 }
151 return NPM_NAME.test(name) ? name.toLowerCase() : undefined
152}
153
154function npmInstalls(argv: string[]): Install[] | undefined {
155 const tool = base(argv[0])
156 const args = argv.slice(1)
157 const sub = args[0]
158 const rest = args.slice(1)
159 let specs: string[] | undefined
160 let isRun = false
161
162 if (tool === 'npm' && sub !== undefined && ['install', 'i', 'add', 'in', 'ins', 'isntall'].includes(sub)) specs = operands(rest, NPM_VALUE_FLAGS)
163 else if ((tool === 'npm' && sub === 'exec') || tool === 'npx' || tool === 'bunx') {
164 // `npx [-p pkg] cmd args`: the package is `-p`'s value, else the first operand.
165 const list = tool === 'npm' ? rest : args
166 const pkg: string[] = []
167 for (let i = 0; i < list.length; i++) if ((list[i] === '-p' || list[i] === '--package') && list[i + 1] !== undefined) pkg.push(list[i + 1]!)
168 const first = operands(list, NPM_VALUE_FLAGS)[0]
169 specs = pkg.length > 0 ? pkg : first === undefined ? [] : [first]
170 isRun = true
171 } else if (tool === 'pnpm' && (sub === 'add' || sub === 'i' || sub === 'install')) specs = operands(rest, NPM_VALUE_FLAGS)
172 else if ((tool === 'pnpm' || tool === 'yarn') && sub === 'dlx') {
173 const first = operands(rest, NPM_VALUE_FLAGS)[0]
174 specs = first === undefined ? [] : [first]
175 isRun = true
176 } else if (tool === 'yarn' && (sub === 'add' || (sub === 'global' && rest[0] === 'add'))) specs = operands(sub === 'global' ? rest.slice(1) : rest, NPM_VALUE_FLAGS)
177 else if (tool === 'bun' && (sub === 'add' || sub === 'a' || sub === 'install' || sub === 'i')) specs = operands(rest, NPM_VALUE_FLAGS)
178 else if (tool === 'bun' && sub === 'x') {
179 const first = operands(rest, NPM_VALUE_FLAGS)[0]
180 specs = first === undefined ? [] : [first]
181 isRun = true
182 }
183 if (specs === undefined) return undefined
184
185 return specs.flatMap(spec => {
186 const name = npmName(spec)
187 return name === undefined ? [] : [{ ecosystem: 'npm' as const, name, spec, isRun }]
188 })
189}
190
191// ---------------------------------------------------------------- PyPI
192
193const PIP_VALUE_FLAGS = new Set(['-r', '--requirement', '-c', '--constraint', '-e', '--editable', '-t', '--target', '--prefix', '--root', '-f', '--find-links', '--trusted-host', '--python', '-p', '--group', '-G', '--extra', '--src', '--platform', '--python-version', '--implementation', '--abi', '--upgrade-strategy', '--progress-bar', '--log', '--cache-dir', '--report', '--source', '--optional', '--with', '--spec', '--from'])
194const PYPI_NAME = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/
195
196/** `Requests[socks]>=2.0; python_version>"3"` → `requests`; undefined for a path, URL or archive. */
197export function pypiName(spec: string): string | undefined {
198 if (/^(\.{0,2}\/|~\/|[a-z+]+:\/\/|git\+|file:)/i.test(spec) || spec === '.' || spec === '..') return undefined
199 if (/\.(whl|tar\.gz|zip|tgz)$/i.test(spec)) return undefined
200 if (/\s@\s|@\s*[a-z+]+:\/\//i.test(spec)) return undefined
201 const name = spec.split(/[[;<>=!~ @]/)[0] ?? ''
202 if (!PYPI_NAME.test(name)) return undefined
203 return name.toLowerCase().replace(/[-_.]+/g, '-')
204}
205
206function pypiInstalls(argv: string[]): Install[] | undefined {
207 let rest = argv
208 let tool = base(rest[0])
209 // `python -m pip install`, `python3.12 -m pip install`, `uv pip install`, `pipx run`.
210 if (/^python(\d(\.\d+)?)?$/.test(tool) && rest[1] === '-m') {
211 rest = rest.slice(2)
212 tool = base(rest[0])
213 }
214 const sub = rest[1]
215 let specs: string[] | undefined
216 let isRun = false
217
218 if (/^pip(\d(\.\d+)?)?$/.test(tool) && sub === 'install') specs = operands(rest.slice(2), PIP_VALUE_FLAGS)
219 else if (tool === 'uv' && sub === 'pip' && rest[2] === 'install') specs = operands(rest.slice(3), PIP_VALUE_FLAGS)
220 else if (tool === 'uv' && sub === 'add') specs = operands(rest.slice(2), PIP_VALUE_FLAGS)
221 else if (tool === 'uv' && sub === 'tool' && (rest[2] === 'install' || rest[2] === 'run')) {
222 specs = operands(rest.slice(3), PIP_VALUE_FLAGS).slice(0, 1)
223 isRun = rest[2] === 'run'
224 } else if (tool === 'uvx') {
225 const from = rest.findIndex(w => w === '--from')
226 specs = from >= 0 && rest[from + 1] !== undefined ? [rest[from + 1]!] : operands(rest.slice(1), PIP_VALUE_FLAGS).slice(0, 1)
227 isRun = true
228 } else if ((tool === 'poetry' || tool === 'pdm' || tool === 'rye' || tool === 'hatch') && sub === 'add') specs = operands(rest.slice(2), PIP_VALUE_FLAGS)
229 else if (tool === 'pipenv' && sub === 'install') specs = operands(rest.slice(2), PIP_VALUE_FLAGS)
230 else if (tool === 'pipx' && (sub === 'install' || sub === 'run')) {
231 specs = operands(rest.slice(2), PIP_VALUE_FLAGS).slice(0, sub === 'run' ? 1 : undefined)
232 isRun = sub === 'run'
233 }
234 if (specs === undefined) return undefined
235
236 return specs.flatMap(spec => {
237 const name = pypiName(spec)
238 return name === undefined ? [] : [{ ecosystem: 'pypi' as const, name, spec, isRun }]
239 })
240}
241
242// ---------------------------------------------------------------- crates.io, RubyGems
243
244const CARGO_VALUE_FLAGS = new Set(['--features', '-F', '--rename', '--package', '-p', '--target', '--branch', '--tag', '--rev', '--version', '--vers', '--root', '--bin', '--example', '--manifest-path', '--profile', '-j', '--jobs', '--target-dir'])
245const GEM_VALUE_FLAGS = new Set(['-v', '--version', '-i', '--install-dir', '-n', '--bindir', '--platform', '-g', '--file', '--group', '--require'])
246const CRATE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/
247const GEM_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
248
249function cargoInstalls(argv: string[]): Install[] | undefined {
250 if (base(argv[0]) !== 'cargo') return undefined
251 const sub = argv[1]
252 if (sub !== 'add' && sub !== 'install') return undefined
253 const args = argv.slice(2)
254 if (hasFlag(args, '--path', '--git')) return []
255 return operands(args, CARGO_VALUE_FLAGS).flatMap(spec => {
256 const name = spec.split('@')[0] ?? ''
257 return CRATE_NAME.test(name) ? [{ ecosystem: 'crates' as const, name: name.toLowerCase(), spec, isRun: false }] : []
258 })
259}
260
261function gemInstalls(argv: string[]): Install[] | undefined {
262 const tool = base(argv[0])
263 const isGem = tool === 'gem' && argv[1] === 'install'
264 const isBundle = tool === 'bundle' && argv[1] === 'add'
265 if (!isGem && !isBundle) return undefined
266 const args = argv.slice(2)
267 if (hasFlag(args, '--local', '--path', '--git', '--github')) return []
268 const specs = isBundle ? operands(args, GEM_VALUE_FLAGS).slice(0, 1) : operands(args, GEM_VALUE_FLAGS)
269 return specs.flatMap(spec => {
270 if (/\.gem$/.test(spec) || spec.includes('/')) return []
271 const name = spec.split(':')[0] ?? ''
272 return GEM_NAME.test(name) ? [{ ecosystem: 'rubygems' as const, name, spec, isRun: false }] : []
273 })
274}
275
276// ---------------------------------------------------------------- all
277
278// Flags that point an installer at another registry or index.
279const REGISTRY_FLAGS: Record<Ecosystem, string[]> = {
280 npm: ['--registry'],
281 pypi: ['-i', '--index-url', '--extra-index-url', '--index', '--default-index'],
282 crates: ['--registry', '--index'],
283 rubygems: ['--source', '-s'],
284}
285
286/** Every registry package `command` installs or runs, and whether it names its own registry. */
287export function parseInstalls(command: string): Parsed {
288 const installs: Install[] = []
289 let hasCustomRegistry = false
290 for (const segment of segments(command)) {
291 const argv = unwrap(words(segment.replace(/^[({\s!]+|[)}\s]+$/g, '')))
292 if (argv.length === 0) continue
293 const found = npmInstalls(argv) ?? pypiInstalls(argv) ?? cargoInstalls(argv) ?? gemInstalls(argv)
294 if (found === undefined) continue
295 const ecosystem: Ecosystem = npmInstalls(argv) !== undefined ? 'npm' : pypiInstalls(argv) !== undefined ? 'pypi' : cargoInstalls(argv) !== undefined ? 'crates' : 'rubygems'
296 if (hasFlag(argv, ...REGISTRY_FLAGS[ecosystem])) {
297 hasCustomRegistry = true
298 continue
299 }
300 installs.push(...found)
301 }
302 // One check per package, even when a command names it twice.
303 const seen = new Set<string>()
304 return {
305 installs: installs.filter(i => (seen.has(`${i.ecosystem}:${i.name}`) ? false : (seen.add(`${i.ecosystem}:${i.name}`), true))),
306 hasCustomRegistry,
307 }
308}
309hooks/verdict.ts 215 lines1// Pure verdicts from registry answers: no `$`, so tests can import it.
2
3import type { Ecosystem } from './parse'
4
5export type Status = 'ok' | 'missing' | 'suspicious' | 'unknown'
6
7export type Verdict = {
8 status: Status
9 reasons: string[]
10 createdAt?: string
11 weeklyDownloads?: number
12}
13
14export type Limits = {
15 minAgeDays: number
16 minWeeklyDownloads: number
17}
18
19/** A registry's answer as `$.http.fetch` gives it, or undefined when there was none. */
20export type Answer = { status: number; text: string } | undefined
21
22export const REGISTRY_NAMES: Record<Ecosystem, string> = {
23 npm: 'npm',
24 pypi: 'PyPI',
25 crates: 'crates.io',
26 rubygems: 'RubyGems',
27}
28
29/** The URLs to ask about one package: its metadata, and its downloads where separate. */
30export function urlsFor(ecosystem: Ecosystem, name: string): { meta: string; downloads?: string } {
31 switch (ecosystem) {
32 case 'npm':
33 return {
34 meta: `https://registry.npmjs.org/${name.replace('/', '%2F')}`,
35 downloads: `https://api.npmjs.org/downloads/point/last-week/${name}`,
36 }
37 case 'pypi':
38 return { meta: `https://pypi.org/pypi/${name}/json`, downloads: `https://pypistats.org/api/packages/${name}/recent` }
39 case 'crates':
40 return { meta: `https://crates.io/api/v1/crates/${name}` }
41 case 'rubygems':
42 return { meta: `https://rubygems.org/api/v1/gems/${name}.json` }
43 }
44}
45
46const DAY = 24 * 60 * 60 * 1000
47
48function parse(text: string): Record<string, unknown> | undefined {
49 try {
50 const value: unknown = JSON.parse(text)
51 return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
52 } catch {
53 return undefined
54 }
55}
56
57const get = (value: unknown, ...path: string[]): unknown =>
58 path.reduce<unknown>((node, key) => (node !== null && typeof node === 'object' ? (node as Record<string, unknown>)[key] : undefined), value)
59
60/** When the package first appeared and how much it is used, per registry. */
61function facts(ecosystem: Ecosystem, meta: Record<string, unknown>, downloads: Record<string, unknown> | undefined): { createdAt?: string; weeklyDownloads?: number } {
62 // npm and PyPI keep downloads apart (weeklyFrom); crates.io and RubyGems carry them here.
63 switch (ecosystem) {
64 case 'npm': {
65 const created = get(meta, 'time', 'created')
66 const weekly = get(downloads, 'downloads')
67 return { createdAt: typeof created === 'string' ? created : undefined, weeklyDownloads: typeof weekly === 'number' ? weekly : undefined }
68 }
69 case 'pypi': {
70 // The earliest upload of any release is when the name was first used.
71 const releases = get(meta, 'releases')
72 let first: string | undefined
73 if (releases !== null && typeof releases === 'object') {
74 for (const files of Object.values(releases as Record<string, unknown>)) {
75 if (!Array.isArray(files)) continue
76 for (const file of files) {
77 const at = get(file, 'upload_time_iso_8601') ?? get(file, 'upload_time')
78 if (typeof at === 'string' && (first === undefined || at < first)) first = at
79 }
80 }
81 }
82 const weekly = get(downloads, 'data', 'last_week')
83 return { createdAt: first, weeklyDownloads: typeof weekly === 'number' ? weekly : undefined }
84 }
85 case 'crates': {
86 const created = get(meta, 'crate', 'created_at')
87 const recent = get(meta, 'crate', 'recent_downloads')
88 return {
89 createdAt: typeof created === 'string' ? created : undefined,
90 weeklyDownloads: typeof recent === 'number' ? Math.round(recent / 13) : undefined,
91 }
92 }
93 case 'rubygems': {
94 // RubyGems reports lifetime downloads only; a year's worth stands in for a week's floor.
95 const total = get(meta, 'downloads')
96 return { weeklyDownloads: typeof total === 'number' ? Math.round(total / 52) : undefined }
97 }
98 }
99}
100
101/** What the registries answered about one package; `meta` is left out when not needed. */
102export type Answers = {
103 /** Whether the name exists: a HEAD of the metadata URL, or the metadata itself. */
104 exists: Answer
105 meta?: Answer
106 downloads?: Answer
107}
108
109/** Weekly downloads from a downloads answer, where the registry keeps them apart. */
110export function weeklyFrom(ecosystem: Ecosystem, downloads: Answer): number | undefined {
111 if (downloads === undefined || downloads.status !== 200) return undefined
112 const body = parse(downloads.text)
113 const value = ecosystem === 'npm' ? get(body, 'downloads') : get(body, 'data', 'last_week')
114 return typeof value === 'number' ? value : undefined
115}
116
117/** Whether a package this used needs no closer look at its metadata. */
118export const isPopular = (weekly: number | undefined): boolean => weekly !== undefined && weekly >= 10_000
119
120/**
121 * The verdict on one package from its registry's answers. A 404 means the
122 * name was never published; any other failure is `unknown`, never a block.
123 */
124export function judge(ecosystem: Ecosystem, name: string, answers: Answers, now: number, limits: Limits): Verdict {
125 const { exists } = answers
126 const registry = REGISTRY_NAMES[ecosystem]
127 if (exists === undefined) return { status: 'unknown', reasons: [`${registry} did not answer`] }
128 if (exists.status === 404) return { status: 'missing', reasons: [`no package named "${name}" exists on ${registry}`] }
129 if (exists.status < 200 || exists.status >= 300) return { status: 'unknown', reasons: [`${registry} answered ${exists.status}`] }
130
131 const meta = answers.meta !== undefined && answers.meta.status >= 200 && answers.meta.status < 300 ? parse(answers.meta.text) : undefined
132 // npm keeps a stub for unpublished names: it has no versions left.
133 if (ecosystem === 'npm' && meta !== undefined && get(meta, 'versions') === undefined && get(meta, 'time', 'unpublished') !== undefined) {
134 return { status: 'missing', reasons: [`"${name}" was unpublished from npm`] }
135 }
136
137 const known = meta === undefined ? {} : facts(ecosystem, meta, undefined)
138 const weeklyDownloads = weeklyFrom(ecosystem, answers.downloads) ?? known.weeklyDownloads
139 const reasons: string[] = []
140 if (known.createdAt !== undefined) {
141 const ageDays = Math.floor((now - Date.parse(known.createdAt)) / DAY)
142 if (Number.isFinite(ageDays) && ageDays < limits.minAgeDays) reasons.push(`it was first published ${ageDays <= 0 ? 'today' : `${ageDays} day${ageDays === 1 ? '' : 's'} ago`}`)
143 }
144 if (weeklyDownloads !== undefined && weeklyDownloads < limits.minWeeklyDownloads) {
145 reasons.push(`it has only ${weeklyDownloads} downloads a week`)
146 }
147 const twin = isPopular(weeklyDownloads) ? undefined : lookalike(ecosystem, name)
148 if (twin !== undefined) reasons.push(`its name is one letter away from the popular "${twin}"`)
149
150 return { status: reasons.length > 0 ? 'suspicious' : 'ok', reasons, createdAt: known.createdAt, weeklyDownloads }
151}
152
153/** Optimal string alignment distance (Levenshtein plus adjacent swaps). */
154export function distance(a: string, b: string): number {
155 const rows = a.length + 1
156 const cols = b.length + 1
157 const d: number[][] = Array.from({ length: rows }, (_, i) => Array.from({ length: cols }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
158 for (let i = 1; i < rows; i++) {
159 for (let j = 1; j < cols; j++) {
160 const cost = a[i - 1] === b[j - 1] ? 0 : 1
161 let best = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + cost)
162 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, d[i - 2]![j - 2]! + 1)
163 d[i]![j] = best
164 }
165 }
166 return d[a.length]![b.length]!
167}
168
169/** The popular package `name` is a near miss of, if any (and `name` is not itself one). */
170export function lookalike(ecosystem: Ecosystem, name: string): string | undefined {
171 const list = POPULAR[ecosystem]
172 const plain = name.replace(/^@[^/]+\//, '')
173 if (list.includes(plain) || plain.length < 4) return undefined
174 return list.find(popular => popular.length >= 4 && Math.abs(popular.length - plain.length) <= 1 && distance(popular, plain) === 1)
175}
176
177// Widely used names, the ones typosquatters aim at.
178export const POPULAR: Record<Ecosystem, readonly string[]> = {
179 npm: [
180 'react', 'react-dom', 'lodash', 'express', 'axios', 'typescript', 'next', 'webpack', 'chalk', 'commander',
181 'moment', 'dayjs', 'uuid', 'debug', 'request', 'async', 'bluebird', 'underscore', 'jquery', 'eslint',
182 'prettier', 'jest', 'mocha', 'chai', 'yargs', 'inquirer', 'dotenv', 'cors', 'body-parser', 'mongoose',
183 'mysql', 'mysql2', 'redis', 'ioredis', 'socket.io', 'node-fetch', 'cross-env', 'rimraf', 'glob', 'minimist',
184 'semver', 'fs-extra', 'mkdirp', 'classnames', 'prop-types', 'redux', 'react-redux', 'react-router', 'react-router-dom', 'styled-components',
185 'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'vite', 'rollup', 'esbuild', 'nodemon', 'ts-node', 'zod',
186 'jsonwebtoken', 'bcrypt', 'bcryptjs', 'passport', 'sequelize', 'prisma', 'knex', 'graphql', 'firebase', 'aws-sdk',
187 'stripe', 'twilio', 'nodemailer', 'sharp', 'multer', 'cheerio', 'puppeteer', 'playwright', 'electron', 'three',
188 'chart.js', 'rxjs', 'immer', 'formik', 'svelte', 'nuxt', 'gatsby', 'husky', 'lint-staged', 'concurrently',
189 'colors', 'boxen', 'figlet', 'marked', 'highlight.js', 'date-fns', 'luxon', 'superagent', 'form-data', 'cookie-parser',
190 'express-session', 'helmet', 'morgan', 'winston', 'pino', 'openai', 'langchain', 'vitest', 'webpack-cli', 'babel-loader',
191 ],
192 pypi: [
193 'requests', 'numpy', 'pandas', 'scipy', 'matplotlib', 'seaborn', 'scikit-learn', 'tensorflow', 'torch', 'keras',
194 'flask', 'django', 'fastapi', 'uvicorn', 'gunicorn', 'pydantic', 'sqlalchemy', 'psycopg2', 'psycopg2-binary', 'pymysql',
195 'redis', 'celery', 'boto3', 'botocore', 'awscli', 'urllib3', 'certifi', 'idna', 'charset-normalizer', 'setuptools',
196 'wheel', 'python-dateutil', 'pytz', 'pyyaml', 'jinja2', 'markupsafe', 'click', 'rich', 'typer', 'tqdm',
197 'pillow', 'opencv-python', 'beautifulsoup4', 'lxml', 'selenium', 'scrapy', 'httpx', 'aiohttp', 'attrs', 'cryptography',
198 'pyjwt', 'paramiko', 'pytest', 'coverage', 'black', 'flake8', 'mypy', 'pylint', 'isort', 'ruff',
199 'poetry', 'openai', 'anthropic', 'langchain', 'transformers', 'datasets', 'huggingface-hub', 'tokenizers', 'sentencepiece', 'nltk',
200 'spacy', 'gensim', 'xgboost', 'lightgbm', 'catboost', 'plotly', 'streamlit', 'gradio', 'jupyter', 'notebook',
201 'ipython', 'ipykernel', 'networkx', 'sympy', 'statsmodels', 'pyarrow', 'polars', 'dask', 'protobuf', 'grpcio',
202 'pymongo', 'elasticsearch', 'kafka-python', 'docker', 'kubernetes', 'ansible', 'python-dotenv', 'colorama', 'termcolor', 'tabulate',
203 'packaging', 'filelock', 'platformdirs', 'virtualenv', 'websockets', 'marshmallow', 'alembic', 'werkzeug', 'simplejson', 'orjson',
204 ],
205 crates: [
206 'serde', 'serde_json', 'tokio', 'rand', 'clap', 'anyhow', 'thiserror', 'reqwest', 'regex', 'chrono',
207 'log', 'env_logger', 'tracing', 'futures', 'hyper', 'axum', 'actix-web', 'syn', 'quote', 'itertools',
208 'once_cell', 'lazy_static', 'bytes', 'uuid', 'sqlx', 'diesel', 'rayon', 'crossbeam', 'parking_lot', 'base64',
209 ],
210 rubygems: [
211 'rails', 'rake', 'rack', 'bundler', 'rspec', 'nokogiri', 'devise', 'puma', 'sidekiq', 'pg',
212 'activesupport', 'activerecord', 'json', 'thor', 'faraday', 'httparty', 'rubocop', 'pry', 'sinatra', 'capybara',
213 ],
214}
215