SLOPSHOPPER

slopsquat-guard

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

newguardcommandtoastnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slopsquat-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /slopsquat ⎿ slopsquat-guard: Usage: /slopsquat <npm|pypi|crates|gem> <name> ⎿ slopsquat-guard: slopsquat-guard has blocked 0 installs this session, 0 all time. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

📦 slopsquat-guard

No more hallucinated packages. Every npm, pip, uv, poetry, cargo and gem install 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.

Features

  • Blocks packages that don't exist on npm, PyPI, crates.io or RubyGems, and npm names that were unpublished. The model is told not to guess another spelling.
  • Asks before risky ones: first published fewer than 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.
  • Reads real install syntax. 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.
  • Ignores what isn't a registry package: bare 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.
  • Fast and polite. Popular npm/PyPI packages are cleared by a HEAD plus a tiny downloads lookup (no multi-megabyte metadata). Verdicts are cached for 24 h (1 h for "missing").
  • Never blocks you for being offline. If a registry doesn't answer within 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).

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install slopsquat-guard@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Configuration

OptionDefaultWhat it does
minAgeDays14A package first published fewer days ago asks first.
minWeeklyDownloads50A package with fewer weekly downloads asks first. RubyGems is estimated from lifetime downloads.
allowListemptyComma-separated names that are never checked: your private and internal packages.
timeoutMs5000How long to wait for a registry before letting the install run unchecked.

How it works

Event / APIWhy
tool.call on BashParses the command for installs, checks each package, returns { deny } for missing ones, asks with $.ui.ask for risky ones, else next(e)
$.http.fetchregistry.npmjs.org and api.npmjs.org, pypi.org and pypistats.org, crates.io, rubygems.org
$.storeThe verdict cache (newest 400, one week) and the all-time blocked count
$.fs.existsSkips 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 $.

Test it

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.

Limitations

  • It checks packages named on the command line. Dependencies pulled in by package.json, requirements.txt, lockfiles or transitively aren't checked.
  • Existing isn't the same as safe. A long-lived, popular package can still be compromised. This guard targets the hallucinated-name and typosquat cases.
  • Go modules, Maven/Gradle, NuGet, Composer and conda aren't covered yet.
  • Each uncached package costs one to three small registry requests, which adds a beat before a fresh install.
Source 3 files
hooks/register.ts 203 lines
1import 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}
203
hooks/parse.ts 309 lines
1// 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}
309
hooks/verdict.ts 215 lines
1// 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