ai-install-guard 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +50 -0
- package/README.md +103 -24
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/gate/gate.ts +116 -0
- package/hooks/gate/recheck.ts +33 -0
- package/hooks/gate/requests.ts +728 -0
- package/hooks/gate/resolve.ts +210 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +44 -3
- package/hooks/guard/pane.tsx +200 -29
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +24 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +117 -75
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +120 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +427 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
|
@@ -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
|
+
}
|