ai-install-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ // Lookups outside the machine, off unless you turn on `outsideLookups`:
2
+ // packages checked against OSV (Google's open vulnerability database, which
3
+ // also lists packages published to steal or harm), and plugin names against
4
+ // Anthropic's official marketplace, to catch a name made to look like one of
5
+ // its plugins. Each sends names to that service, so it is your choice. A
6
+ // lookup that fails is noted and never fails the scan.
7
+
8
+ import { isRecord, parseJson, type ScanContext } from './checks/context'
9
+ import { capEvidence, row, type CheckResult, type Evidence } from './checks/types'
10
+ import type { FetchResult, Host } from './host'
11
+ import { safeLine } from './safe-text'
12
+
13
+ export const OSV_BATCH = 'https://api.osv.dev/v1/querybatch'
14
+ export const OSV_VULN = 'https://api.osv.dev/v1/vulns/'
15
+ /** How many advisories' details are read to tell malware from a bug. */
16
+ const MAX_DETAILS = 300
17
+ /** How many of a candidate's plugin names are compared with the official list. */
18
+ const MAX_NAMES = 2000
19
+ export const OFFICIAL_MARKETPLACE = 'https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json'
20
+ /** OSV takes up to 1,000 queries a request; past this many, the rest are reported as not checked. */
21
+ const BATCH = 1000
22
+ const MAX_QUERIES = 3000
23
+
24
+ type Package = { ecosystem: 'npm' | 'PyPI'; name: string; version?: string; file: string; via?: string }
25
+
26
+ /**
27
+ * What one scan has already asked: a marketplace scan runs the lookups once
28
+ * for itself and once for each plugin it lists, and these are the same.
29
+ */
30
+ export type LookupCache = { official?: Promise<FetchResult>; details: Map<string, Promise<FetchResult | undefined>> }
31
+
32
+ export function newLookupCache(): LookupCache {
33
+ return { details: new Map() }
34
+ }
35
+
36
+ export async function outsideLookups(host: Host, ctx: ScanContext, cache: LookupCache = newLookupCache()): Promise<CheckResult[]> {
37
+ if (host.fetch === undefined) return []
38
+ return [await osvCheck(host, ctx, cache), ...(await namesCheck(host, ctx, cache))]
39
+ }
40
+
41
+ function failedRow(id: string, title: string, reason: string): CheckResult {
42
+ return {
43
+ id,
44
+ group: 'provenance',
45
+ title,
46
+ status: 'info',
47
+ severity: 'none',
48
+ evidence: [{ file: '-', excerpt: `not checked: ${safeLine(reason)}`, status: 'info' }],
49
+ advice: 'The lookup did not answer, so this part was not checked.',
50
+ summary: [`Not checked: ${safeLine(reason)}`],
51
+ }
52
+ }
53
+
54
+ const EXACT = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/
55
+
56
+ /**
57
+ * What a runner's options do with the word after them: name the package
58
+ * (`package`), add an extra one beside it (`extra`), or take some other
59
+ * value (`value`). Each runner has its own: `-p` names the package to npx
60
+ * but the Python version to uvx. An option not listed takes no value.
61
+ */
62
+ type OptionRole = 'package' | 'extra' | 'value'
63
+ const NPM_OPTIONS: Record<string, OptionRole> = {
64
+ '-p': 'package', '--package': 'package',
65
+ '-c': 'value', '--call': 'value', '-w': 'value', '--workspace': 'value', '--registry': 'value', '--cache': 'value', '--userconfig': 'value', '--prefix': 'value', '--node-options': 'value', '--shell': 'value',
66
+ }
67
+ const UV_OPTIONS: Record<string, OptionRole> = {
68
+ '--from': 'package', '--with': 'extra', '-w': 'extra',
69
+ '--with-requirements': 'value', '--with-editable': 'value', '-p': 'value', '--python': 'value', '-i': 'value', '--index-url': 'value', '--extra-index-url': 'value', '--index': 'value', '--default-index': 'value',
70
+ '-f': 'value', '--find-links': 'value', '--constraints': 'value', '--overrides': 'value', '--cache-dir': 'value', '--directory': 'value', '--project': 'value', '--config-file': 'value', '--env-file': 'value',
71
+ '--keyring-provider': 'value', '--resolution': 'value', '--prerelease': 'value', '--exclude-newer': 'value', '--python-preference': 'value', '--python-platform': 'value', '--color': 'value',
72
+ }
73
+ const PIPX_OPTIONS: Record<string, OptionRole> = { '--spec': 'package', '--python': 'value', '--index-url': 'value', '--pip-args': 'value' }
74
+
75
+ /** The packages a candidate installs or runs: its MCP servers' and its manifests'. */
76
+ export function packagesOf(ctx: ScanContext): Package[] {
77
+ const out: Package[] = []
78
+ const add = (pkg: Package) => {
79
+ if (!out.some(item => item.ecosystem === pkg.ecosystem && item.name === pkg.name && item.version === pkg.version)) out.push(pkg)
80
+ }
81
+ const addSpec = (spec: string, python: boolean, file: string, via: string) => {
82
+ if (python) {
83
+ // Extras (httpx[cli]) name optional parts of the same package.
84
+ const [rawName, version] = spec.split(/==|@/)
85
+ const name = rawName?.replace(/\[[^\]]{0,200}\]$/, '')
86
+ if (name !== undefined && /^[A-Za-z0-9._-]{1,100}$/.test(name)) add({ ecosystem: 'PyPI', name, version: version !== undefined && EXACT.test(version) ? version : undefined, file, via })
87
+ return
88
+ }
89
+ const at = spec.lastIndexOf('@')
90
+ const name = at > 0 ? spec.slice(0, at) : spec
91
+ const version = at > 0 ? spec.slice(at + 1) : undefined
92
+ if (/^(?:@[\w.-]+\/)?[\w.-]{1,100}$/.test(name)) add({ ecosystem: 'npm', name, version: version !== undefined && EXACT.test(version) ? version : undefined, file, via })
93
+ }
94
+ for (const server of ctx.mcpServers) {
95
+ const words = [server.command ?? '', ...server.args].join(' ').split(/\s+/).filter(word => word !== '')
96
+ const base = (word: string | undefined) => word?.split(/[\\/]/).pop() ?? ''
97
+ const runner = words.findIndex(word => /^(?:npx|bunx|pnpx|uvx)$/.test(base(word)))
98
+ const exec = words.findIndex((word, index) => /^(?:npm|pnpm|yarn|bun)$/.test(base(word)) && /^(?:exec|dlx|x)$/.test(words[index + 1] ?? ''))
99
+ const pipx = words.findIndex((word, index) => base(word) === 'pipx' && words[index + 1] === 'run')
100
+ const start = runner !== -1 ? runner + 1 : exec !== -1 ? exec + 2 : pipx !== -1 ? pipx + 2 : -1
101
+ if (start === -1) continue
102
+ const python = runner !== -1 ? base(words[runner]) === 'uvx' : pipx !== -1
103
+ const options = pipx !== -1 ? PIPX_OPTIONS : python ? UV_OPTIONS : NPM_OPTIONS
104
+ // Options with values are skipped. One that names the package makes the
105
+ // word after the options only the command to run; one that adds an extra
106
+ // package leaves the main package to come.
107
+ let named = false
108
+ for (let index = start; index < words.length; index += 1) {
109
+ const word = words[index] as string
110
+ if (word === '--') continue
111
+ if (word.startsWith('-')) {
112
+ const [flag, inline] = word.split('=', 2) as [string, string | undefined]
113
+ const role = options[flag]
114
+ if (role === undefined) continue
115
+ const value = inline ?? words[index + 1]
116
+ if (inline === undefined) index += 1
117
+ if (value !== undefined && role !== 'value') {
118
+ addSpec(value, python, server.file, server.name)
119
+ if (role === 'package') named = true
120
+ }
121
+ continue
122
+ }
123
+ // A path or a git or URL spec (./server.js, github:me/tool) is what
124
+ // runs, and it is no registry package: nothing after it is one either.
125
+ // A scoped npm name (@scope/name) is a name, and so is one that merely
126
+ // ends like a file.
127
+ const isLocalOrGit = /^(?:\.{1,2}[\\/]|[\\/~])|^[a-z][\w+.-]*:/i.test(word) || (/[\\/]/.test(word) && !/^@[\w.-]+\/[\w.-]+(?:@[^\\/\s]*)?$/.test(word))
128
+ if (!named && !isLocalOrGit) addSpec(word, python, server.file, server.name)
129
+ break
130
+ }
131
+ }
132
+ for (const file of ctx.files) {
133
+ const name = file.path.split('/').pop() ?? ''
134
+ if (name === 'package.json') {
135
+ const parsed = parseJson(ctx.text(file.path))
136
+ if (!parsed.ok || !isRecord(parsed.value)) continue
137
+ const dir = file.path.slice(0, -'package.json'.length)
138
+ const lock = parseJson(ctx.text(`${dir}package-lock.json`))
139
+ const locked = lock.ok && isRecord(lock.value) && isRecord(lock.value.packages) ? lock.value.packages : {}
140
+ for (const section of ['dependencies', 'optionalDependencies', 'devDependencies']) {
141
+ const deps = parsed.value[section]
142
+ if (!isRecord(deps)) continue
143
+ for (const [dep, spec] of Object.entries(deps)) {
144
+ const entry = locked[`node_modules/${dep}`]
145
+ const fromLock = isRecord(entry) ? entry.version : undefined
146
+ const version = typeof spec === 'string' && EXACT.test(spec) ? spec : typeof fromLock === 'string' && EXACT.test(fromLock) ? fromLock : undefined
147
+ if (version !== undefined) add({ ecosystem: 'npm', name: dep, version, file: file.path, via: section === 'devDependencies' ? 'for development' : undefined })
148
+ }
149
+ }
150
+ } else if (/^requirements(?:[-.\w]*)?\.txt$/.test(name)) {
151
+ for (const line of (ctx.text(file.path) ?? '').split('\n')) {
152
+ const pin = /^\s*([A-Za-z0-9._-]{1,100})(?:\[[^\]]*\])?\s*==\s*([\w.+!-]{1,50})\s*(?:[#;].*)?$/.exec(line)
153
+ if (pin !== null) add({ ecosystem: 'PyPI', name: pin[1] as string, version: pin[2] as string, file: file.path })
154
+ }
155
+ }
156
+ }
157
+ return out
158
+ }
159
+
160
+ async function osvCheck(host: Host, ctx: ScanContext, cache: LookupCache): Promise<CheckResult> {
161
+ const found = packagesOf(ctx)
162
+ const packages = found.slice(0, MAX_QUERIES)
163
+ const base = { id: 'provenance.packages', group: 'provenance' as const, title: 'Known malicious or vulnerable packages' }
164
+ if (packages.length === 0) return row({ ...base, advice: 'No packages to look up.' }, [])
165
+ const fetch = host.fetch as NonNullable<Host['fetch']>
166
+ // One list of advisory ids per package, in order, or undefined for a
167
+ // package whose batch got no usable answer. OSV answers in batches, and a
168
+ // batch that fails never discards what earlier batches found.
169
+ const ids: (string[] | undefined)[] = []
170
+ let failure: string | undefined
171
+ for (let start = 0; start < packages.length; start += BATCH) {
172
+ const batch = packages.slice(start, start + BATCH)
173
+ let results: unknown
174
+ try {
175
+ const answer = await fetch(OSV_BATCH, {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({ queries: batch.map(pkg => ({ package: { name: pkg.name, ecosystem: pkg.ecosystem }, ...(pkg.version === undefined ? {} : { version: pkg.version }) })) }),
179
+ })
180
+ const parsed = parseJson(answer.text)
181
+ results = answer.ok && parsed.ok && isRecord(parsed.value) ? parsed.value.results : undefined
182
+ if (!answer.ok) failure = `OSV answered ${answer.status}`
183
+ } catch (error) {
184
+ failure = `could not reach OSV: ${error instanceof Error ? error.message : String(error)}`
185
+ }
186
+ // A reply that does not answer every package is not trusted for any of its batch.
187
+ if (!Array.isArray(results) || results.length !== batch.length) {
188
+ failure ??= 'OSV gave an answer that does not cover every package'
189
+ for (let index = 0; index < batch.length; index += 1) ids.push(undefined)
190
+ continue
191
+ }
192
+ for (const entry of results) {
193
+ // OSV answers {} for a package with no advisories; anything that is not
194
+ // an object is no answer, and that package counts as not looked up.
195
+ if (!isRecord(entry)) {
196
+ ids.push(undefined)
197
+ failure ??= 'OSV gave no usable answer for some packages'
198
+ continue
199
+ }
200
+ ids.push(Array.isArray(entry.vulns) ? entry.vulns.map(vuln => (isRecord(vuln) && typeof vuln.id === 'string' ? vuln.id : '')).filter(id => /^[A-Z][\w.-]{2,60}$/.test(id)) : [])
201
+ }
202
+ }
203
+ const unchecked = ids.filter(list => list === undefined).length
204
+ if (unchecked === packages.length) return failedRow(base.id, base.title, failure ?? 'OSV gave no usable answer')
205
+ // Malware is often filed as an ordinary advisory (GHSA) marked CWE-506, so
206
+ // each id's details are read; MAL- ids are malware by definition. An id
207
+ // whose details could not be read stays unresolved, never cleared.
208
+ const all = [...new Set(ids.flatMap(list => list ?? []))]
209
+ const harmful = new Set(all.filter(id => id.startsWith('MAL-')))
210
+ const resolved = new Set(harmful)
211
+ const wanted = all.filter(id => !id.startsWith('MAL-')).slice(0, MAX_DETAILS)
212
+ for (let start = 0; start < wanted.length; start += 8) {
213
+ await Promise.all(
214
+ wanted.slice(start, start + 8).map(async id => {
215
+ try {
216
+ let pending = cache.details.get(id)
217
+ if (pending === undefined) {
218
+ pending = fetch(`${OSV_VULN}${encodeURIComponent(id)}`).catch(() => undefined)
219
+ cache.details.set(id, pending)
220
+ }
221
+ const answer = await pending
222
+ if (answer === undefined) return
223
+ const parsed = parseJson(answer.text)
224
+ // Only the advisory's own record counts as read: an empty or other
225
+ // answer leaves it unresolved.
226
+ if (!answer.ok || !parsed.ok || !isRecord(parsed.value) || parsed.value.id !== id) return
227
+ const details = parsed.value
228
+ const cwes = isRecord(details.database_specific) && Array.isArray(details.database_specific.cwe_ids) ? details.database_specific.cwe_ids : []
229
+ if (cwes.includes('CWE-506') || (typeof details.summary === 'string' && /\bmalware\b|\bmalicious\b/i.test(details.summary))) harmful.add(id)
230
+ resolved.add(id)
231
+ } catch {
232
+ // Left unresolved.
233
+ }
234
+ }),
235
+ )
236
+ }
237
+ const evidence: Evidence[] = []
238
+ const malicious: string[] = []
239
+ const vulnerable: string[] = []
240
+ const unknown: string[] = []
241
+ packages.forEach((pkg, index) => {
242
+ const list = ids[index]
243
+ if (list === undefined) return
244
+ const label = `${pkg.name}${pkg.version === undefined ? '' : `@${pkg.version}`} (${pkg.ecosystem})`
245
+ const lead = pkg.via === undefined ? '' : `${pkg.via}: `
246
+ const bad = list.filter(id => harmful.has(id))
247
+ const open = list.filter(id => !resolved.has(id))
248
+ if (bad.length > 0) {
249
+ malicious.push(label)
250
+ evidence.push({ file: pkg.file, excerpt: `${lead}${label} is listed as malware: ${bad.slice(0, 3).join(', ')}`, status: 'fail' })
251
+ } else if (pkg.version !== undefined && list.length > 0) {
252
+ vulnerable.push(label)
253
+ const note = open.length > 0 ? `; ${open.length} could not be read, and any of them may be malware` : ''
254
+ evidence.push({ file: pkg.file, excerpt: `${lead}${label} has ${list.length} known vulnerabilit${list.length === 1 ? 'y' : 'ies'}: ${list.slice(0, 4).join(', ')}${list.length > 4 ? ' and more' : ''}${note}`, status: 'warn' })
255
+ } else if (open.length > 0) {
256
+ // Unpinned, so only malware counts; and these could not be ruled out.
257
+ unknown.push(label)
258
+ evidence.push({ file: pkg.file, excerpt: `${lead}${label} has ${open.length} advisor${open.length === 1 ? 'y' : 'ies'} whose details could not be read; any of them may be malware`, status: 'warn' })
259
+ }
260
+ })
261
+ const notLooked = found.length - packages.length + unchecked
262
+ if (notLooked > 0) evidence.push({ file: '-', excerpt: `${notLooked} package${notLooked === 1 ? ' was' : 's were'} not looked up${failure === undefined ? '' : ` (${failure})`}`, status: 'warn' })
263
+ // The reason rides in the summary, which no evidence cap can drop.
264
+ const checked = `Checked ${packages.length - unchecked} package${packages.length - unchecked === 1 ? '' : 's'} against OSV${notLooked > 0 ? `; ${notLooked} more ${notLooked === 1 ? 'was' : 'were'} not looked up${failure === undefined ? '' : ` (${failure})`}` : ''}`
265
+ const summary =
266
+ malicious.length > 0
267
+ ? [`Uses a package listed as malware: ${malicious.slice(0, 3).join(', ')}`, checked]
268
+ : vulnerable.length + unknown.length > 0
269
+ ? [`${vulnerable.length + unknown.length} package${vulnerable.length + unknown.length === 1 ? ' has' : 's have'} advisories: ${[...vulnerable, ...unknown].slice(0, 3).join(', ')}`, checked]
270
+ : notLooked > 0
271
+ ? [checked]
272
+ : undefined
273
+ return row(
274
+ {
275
+ ...base,
276
+ advice: 'A package OSV lists as malware was published to do harm: do not install. A known vulnerability may or may not be reachable from this plugin; a newer version usually fixes it.',
277
+ summary,
278
+ },
279
+ capEvidence(evidence, 20),
280
+ )
281
+ }
282
+
283
+ /** Edit distance, stopping once it passes `limit`. */
284
+ function distance(a: string, b: string, limit: number): number {
285
+ if (Math.abs(a.length - b.length) > limit) return limit + 1
286
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index)
287
+ for (let i = 1; i <= a.length; i += 1) {
288
+ const current = [i]
289
+ let best = i
290
+ for (let j = 1; j <= b.length; j += 1) {
291
+ const value = Math.min((previous[j] as number) + 1, (current[j - 1] as number) + 1, (previous[j - 1] as number) + (a[i - 1] === b[j - 1] ? 0 : 1))
292
+ current.push(value)
293
+ best = Math.min(best, value)
294
+ }
295
+ if (best > limit) return limit + 1
296
+ previous = current
297
+ }
298
+ return previous[b.length] as number
299
+ }
300
+
301
+ /** A name with look-alike characters folded: 0 to o, 1 to l, separators dropped. */
302
+ function folded(name: string): string {
303
+ return name.toLowerCase().replace(/0/g, 'o').replace(/[1|]/g, 'l').replace(/3/g, 'e').replace(/5/g, 's').replace(/[^a-z0-9]/g, '')
304
+ }
305
+
306
+ async function namesCheck(host: Host, ctx: ScanContext, cache: LookupCache): Promise<CheckResult[]> {
307
+ const names = new Set<string>()
308
+ if (typeof ctx.manifest?.name === 'string') names.add(ctx.manifest.name)
309
+ const market = parseJson(ctx.text('.claude-plugin/marketplace.json'))
310
+ if (market.ok && isRecord(market.value) && Array.isArray(market.value.plugins)) {
311
+ for (const plugin of market.value.plugins) if (isRecord(plugin) && typeof plugin.name === 'string') names.add(plugin.name)
312
+ }
313
+ // A bounded amount of work, whatever the file holds; the rest is reported.
314
+ const all = [...names]
315
+ const compared = all.slice(0, MAX_NAMES)
316
+ if (names.size === 0) return []
317
+ const title = 'Names like official plugins'
318
+ let official: string[]
319
+ try {
320
+ cache.official ??= (host.fetch as NonNullable<Host['fetch']>)(OFFICIAL_MARKETPLACE)
321
+ const answer = await cache.official
322
+ const parsed = parseJson(answer.text)
323
+ if (!answer.ok || !parsed.ok || !isRecord(parsed.value) || !Array.isArray(parsed.value.plugins)) return [failedRow('provenance.names', title, `the official list answered ${answer.status}`)]
324
+ official = parsed.value.plugins.map(plugin => (isRecord(plugin) && typeof plugin.name === 'string' ? plugin.name : '')).filter(name => name !== '')
325
+ } catch (error) {
326
+ return [failedRow('provenance.names', title, `could not reach the official list: ${error instanceof Error ? error.message : String(error)}`)]
327
+ }
328
+ const evidence: Evidence[] = []
329
+ const near: string[] = []
330
+ // Said first, so no cap on the evidence can drop it.
331
+ const skipped = all.length - compared.length
332
+ if (skipped > 0) evidence.push({ file: '-', excerpt: `${skipped} more names were not compared`, status: 'warn' })
333
+ for (const name of compared) {
334
+ if (official.includes(name)) {
335
+ evidence.push({ file: '-', excerpt: `${safeLine(name)} has the same name as an official plugin: make sure this is that plugin, from its own source`, status: 'info' })
336
+ continue
337
+ }
338
+ const mine = folded(name)
339
+ const match = official.find(other => {
340
+ const theirs = folded(other)
341
+ if (theirs === mine) return true
342
+ const limit = Math.min(mine.length, theirs.length) >= 8 ? 2 : Math.min(mine.length, theirs.length) >= 5 ? 1 : 0
343
+ return limit > 0 && distance(mine, theirs, limit) <= limit
344
+ })
345
+ if (match !== undefined) {
346
+ const excerpt = `${safeLine(name)} looks like the official plugin ${safeLine(match)}`
347
+ near.push(excerpt)
348
+ evidence.push({ file: '-', excerpt, status: 'warn' })
349
+ }
350
+ }
351
+ return [
352
+ row(
353
+ {
354
+ id: 'provenance.names',
355
+ group: 'provenance',
356
+ title,
357
+ advice: "A name one letter away from an official plugin's is a common way to be installed by mistake. Check the publisher before you trust it.",
358
+ // A look-alike only from a real comparison; names left uncompared are said apart.
359
+ summary: (() => {
360
+ // The headline carries both, since it is all a collapsed row or a verdict reason shows.
361
+ const unchecked = skipped > 0 ? `${skipped} more names were not compared with the official list` : undefined
362
+ if (near.length === 0) return unchecked === undefined ? undefined : [unchecked]
363
+ const found = `Its name looks like an official plugin's: ${near.slice(0, 3).join('; ')}${near.length > 3 ? ` and ${near.length - 3} more` : ''}`
364
+ return [unchecked === undefined ? found : `${found}; ${unchecked}`]
365
+ })(),
366
+ },
367
+ capEvidence(evidence, 20),
368
+ ),
369
+ ]
370
+ }