ai-install-guard 0.0.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,219 @@
1
+ // What GitHub says about a repository the guard fetched from it: how old it
2
+ // and its owner are, how many people work on it, and whether the scanned
3
+ // commit is signed and part of the repository's own history. GitHub is the
4
+ // host the code came from, so asking it reveals nothing new. Every answer is
5
+ // information only, except a brand-new repository or account, and a commit
6
+ // that is not on the default branch, which are warnings. A failed lookup is
7
+ // noted and never fails the scan.
8
+
9
+ import { row, type CheckResult, type Evidence, type Status } from './checks/types'
10
+ import type { FetchResult, Host } from './host'
11
+ import type { PinnedSource } from './remote'
12
+ import { safeLine } from './safe-text'
13
+
14
+ const API = 'https://api.github.com'
15
+ const HEADERS = { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'ai-install-guard' }
16
+ const DAY = 24 * 60 * 60 * 1000
17
+ /** Younger than this, a repository or an account is worth a warning. */
18
+ const NEW_DAYS = 30
19
+
20
+ type Json = Record<string, unknown>
21
+
22
+ class LookupError extends Error {}
23
+
24
+ function isJson(value: unknown): value is Json {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
26
+ }
27
+
28
+ /** owner/repo for a github.com source, or undefined for any other host. */
29
+ export function githubRepo(url: string): { owner: string; repo: string } | undefined {
30
+ const match = /^https:\/\/github\.com\/([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(url)
31
+ return match === null ? undefined : { owner: match[1] as string, repo: match[2] as string }
32
+ }
33
+
34
+ async function api(host: Host, path: string): Promise<{ status: number; body: unknown; headers: Record<string, string> }> {
35
+ if (host.fetch === undefined) throw new LookupError('no network here')
36
+ let answer: FetchResult
37
+ try {
38
+ answer = await host.fetch(`${API}${path}`, { headers: HEADERS })
39
+ } catch (error) {
40
+ throw new LookupError(`could not reach GitHub: ${error instanceof Error ? error.message : String(error)}`)
41
+ }
42
+ if ((answer.status === 403 || answer.status === 429) && (answer.headers['x-ratelimit-remaining'] === '0' || answer.status === 429)) {
43
+ throw new LookupError("GitHub's limit for lookups without an account was reached; try again within the hour")
44
+ }
45
+ let body: unknown
46
+ try {
47
+ body = JSON.parse(answer.text)
48
+ } catch {
49
+ body = undefined
50
+ }
51
+ return { status: answer.status, body, headers: answer.headers }
52
+ }
53
+
54
+ function age(iso: unknown, now: number): number | undefined {
55
+ if (typeof iso !== 'string') return undefined
56
+ const time = Date.parse(iso)
57
+ return Number.isNaN(time) ? undefined : Math.max(0, Math.floor((now - time) / DAY))
58
+ }
59
+
60
+ function spoken(days: number): string {
61
+ if (days < 1) return 'today'
62
+ if (days < 60) return `${days} day${days === 1 ? '' : 's'} ago`
63
+ if (days < 730) return `${Math.round(days / 30)} months ago`
64
+ return `${Math.round(days / 365)} years ago`
65
+ }
66
+
67
+ function count(value: unknown): number | undefined {
68
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
69
+ }
70
+
71
+ /** How many contributors, from the last page number of a one-per-page listing. */
72
+ async function contributors(host: Host, path: string): Promise<number | undefined> {
73
+ const answer = await api(host, `${path}/contributors?per_page=1&anon=1`)
74
+ if (answer.status === 204) return 0
75
+ if (answer.status !== 200) return undefined
76
+ const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(answer.headers.link ?? '')
77
+ if (last !== null) return Number(last[1])
78
+ return Array.isArray(answer.body) ? answer.body.length : undefined
79
+ }
80
+
81
+ export async function githubProvenance(host: Host, source: PinnedSource, now = Date.now()): Promise<CheckResult[]> {
82
+ const repo = githubRepo(source.url)
83
+ if (repo === undefined || host.fetch === undefined) return []
84
+ const path = `/repos/${repo.owner}/${repo.repo}`
85
+ try {
86
+ return await lookups(host, path, source, now)
87
+ } catch (error) {
88
+ const reason = error instanceof LookupError ? error.message : `the lookup failed: ${error instanceof Error ? error.message : String(error)}`
89
+ return [
90
+ {
91
+ id: 'provenance.repository',
92
+ group: 'provenance',
93
+ title: 'Repository and its owner',
94
+ status: 'info',
95
+ severity: 'none',
96
+ evidence: [{ file: '-', excerpt: `not checked: ${safeLine(reason)}`, status: 'info' }],
97
+ advice: "The repository's history could not be read, so the verdict rests on the code alone.",
98
+ summary: [`Not checked: ${safeLine(reason)}`],
99
+ },
100
+ ]
101
+ }
102
+ }
103
+
104
+ async function lookups(host: Host, path: string, source: PinnedSource, now: number): Promise<CheckResult[]> {
105
+ const repoAnswer = await api(host, path)
106
+ if (repoAnswer.status === 404) throw new LookupError('GitHub does not show this repository')
107
+ if (repoAnswer.status !== 200 || !isJson(repoAnswer.body)) throw new LookupError(`GitHub answered ${repoAnswer.status}`)
108
+ const info = repoAnswer.body
109
+ const owner = isJson(info.owner) ? info.owner : {}
110
+ const login = typeof owner.login === 'string' ? owner.login : undefined
111
+ // After the repository itself, a request that fails is noted where it
112
+ // belongs and the rest still count: a late failure never hides a warning.
113
+ const notChecked: string[] = []
114
+ const attempt = async <T>(what: string, run: () => Promise<T>): Promise<T | undefined> => {
115
+ try {
116
+ return await run()
117
+ } catch (error) {
118
+ notChecked.push(`${what}: ${error instanceof LookupError ? error.message : error instanceof Error ? error.message : String(error)}`)
119
+ return undefined
120
+ }
121
+ }
122
+ const ownerAnswer = login === undefined ? undefined : await attempt('its owner', () => api(host, `/users/${encodeURIComponent(login)}`))
123
+ const ownerInfo = ownerAnswer !== undefined && ownerAnswer.status === 200 && isJson(ownerAnswer.body) ? ownerAnswer.body : {}
124
+ const people = await attempt('its contributors', () => contributors(host, path))
125
+
126
+ const repoDays = age(info.created_at, now)
127
+ const ownerDays = age(ownerInfo.created_at, now)
128
+ const pushedDays = age(info.pushed_at, now)
129
+ const stars = count(info.stargazers_count)
130
+ const forks = count(info.forks_count)
131
+ const who = `${typeof owner.type === 'string' && owner.type === 'Organization' ? 'organization' : 'account'} ${safeLine(login ?? 'unknown')}`
132
+
133
+ // The warning signs: an account made days ago, or a new repository with a
134
+ // single author that hardly anyone uses. A new repository from an
135
+ // established account, with others working on it, is only noted.
136
+ const newAccount = ownerDays !== undefined && ownerDays < NEW_DAYS
137
+ // Only facts GitHub gave count: a count it did not give is not taken as low.
138
+ const loneNewRepo = repoDays !== undefined && repoDays < NEW_DAYS && people !== undefined && people <= 1 && stars !== undefined && stars < 10
139
+ const evidence: Evidence[] = []
140
+ const note = (excerpt: string, status: Status = 'info') => evidence.push({ file: '-', excerpt, status })
141
+ if (repoDays !== undefined) note(`created ${spoken(repoDays)}${loneNewRepo ? ', with a single contributor and few stars' : ''}`, loneNewRepo ? 'warn' : 'info')
142
+ if (ownerDays !== undefined) note(`owned by the ${who}, created ${spoken(ownerDays)}`, newAccount ? 'warn' : 'info')
143
+ note(`${stars ?? '?'} stars, ${forks ?? '?'} forks, ${people ?? '?'} contributor${people === 1 ? '' : 's'}`)
144
+ if (pushedDays !== undefined) note(`last pushed ${spoken(pushedDays)}`)
145
+ if (info.archived === true) note('archived: it gets no more fixes')
146
+ for (const what of notChecked) note(`not checked, ${safeLine(what)}`)
147
+ if (info.fork === true) {
148
+ const parent = isJson(info.parent) && typeof info.parent.full_name === 'string' ? safeLine(info.parent.full_name) : 'another repository'
149
+ note(`a fork of ${parent}: compare it with the original`)
150
+ }
151
+
152
+ const young = [
153
+ loneNewRepo && repoDays !== undefined ? `the repository is ${spoken(repoDays).replace(' ago', ' old')}, with one contributor and few stars` : '',
154
+ newAccount && ownerDays !== undefined ? `its owner's account is ${spoken(ownerDays).replace(' ago', ' old')}` : '',
155
+ ].filter(text => text !== '')
156
+ const summary = [
157
+ young.length > 0
158
+ ? `New: ${young.join(' and ')}`
159
+ : `${repoDays === undefined ? 'A repository' : `Created ${spoken(repoDays)}`} by the ${who}${ownerDays === undefined ? '' : ` (on GitHub since ${spoken(ownerDays)})`}`,
160
+ `${stars ?? '?'} stars, ${forks ?? '?'} forks, ${people ?? '?'} contributor${people === 1 ? '' : 's'}${pushedDays === undefined ? '' : `; last pushed ${spoken(pushedDays)}`}`,
161
+ ]
162
+ // A part that could not be read is said in the summary too, not only below it.
163
+ if (notChecked.length > 0) summary.push(`Not checked: ${notChecked.map(safeLine).join('; ')}`)
164
+ const repository = row(
165
+ {
166
+ id: 'provenance.repository',
167
+ group: 'provenance',
168
+ title: 'Repository and its owner',
169
+ advice: 'A brand-new repository or account has no track record. Most are honest, but most malicious plugins are new too: look closer before you trust one.',
170
+ summary,
171
+ },
172
+ evidence,
173
+ 'info',
174
+ )
175
+
176
+ // The scanned commit: signed, and part of the repository's own history.
177
+ const commitEvidence: Evidence[] = []
178
+ const commitTried = await attempt('the commit', () => api(host, `${path}/commits/${source.sha}`))
179
+ const commit = commitTried ?? { status: 0, body: undefined, headers: {} }
180
+ if (commitTried === undefined) {
181
+ commitEvidence.push({ file: '-', excerpt: `not checked: ${safeLine(notChecked.at(-1) ?? 'the request failed')}`, status: 'info' })
182
+ } else if (commit.status === 200 && isJson(commit.body) && isJson(commit.body.commit)) {
183
+ const verification = isJson(commit.body.commit.verification) ? commit.body.commit.verification : {}
184
+ const committer = isJson(commit.body.commit.committer) ? commit.body.commit.committer : {}
185
+ const committedDays = age(committer.date, now)
186
+ commitEvidence.push({
187
+ file: '-',
188
+ excerpt: verification.verified === true ? 'signed, and GitHub verified the signature' : 'not signed, or the signature could not be verified',
189
+ status: 'info',
190
+ })
191
+ if (committedDays !== undefined) commitEvidence.push({ file: '-', excerpt: `committed ${spoken(committedDays)}`, status: 'info' })
192
+ } else if (commit.status === 404 || commit.status === 422) {
193
+ commitEvidence.push({ file: '-', excerpt: 'GitHub does not show this commit in this repository', status: 'warn' })
194
+ } else if (commitTried !== undefined) {
195
+ commitEvidence.push({ file: '-', excerpt: `not checked: GitHub answered ${commit.status}`, status: 'info' })
196
+ }
197
+ // A commit named by id could come from a fork in the same network (an
198
+ // "impostor commit"); one reached through a branch or tag cannot.
199
+ const defaultBranch = typeof info.default_branch === 'string' ? info.default_branch : undefined
200
+ if (source.refKind === 'commit' && defaultBranch !== undefined && commit.status === 200) {
201
+ const compared = (await attempt('the default branch', () => api(host, `${path}/compare/${encodeURIComponent(defaultBranch)}...${source.sha}`))) ?? { status: 0, body: undefined, headers: {} }
202
+ const status = isJson(compared.body) && typeof compared.body.status === 'string' ? compared.body.status : undefined
203
+ if (status === 'behind' || status === 'identical') commitEvidence.push({ file: '-', excerpt: `part of the default branch ${safeLine(defaultBranch)}`, status: 'info' })
204
+ else if (status !== undefined) commitEvidence.push({ file: '-', excerpt: `not on the default branch ${safeLine(defaultBranch)}: it may come from another branch or from a fork`, status: 'warn' })
205
+ else commitEvidence.push({ file: '-', excerpt: `whether it is on the default branch was not checked: ${compared.status === 0 ? safeLine(notChecked.at(-1) ?? 'the request failed') : `GitHub answered ${compared.status}`}`, status: 'info' })
206
+ }
207
+ const commitRow = row(
208
+ {
209
+ id: 'provenance.commit',
210
+ group: 'provenance',
211
+ title: 'The scanned commit',
212
+ advice: "A commit named by its id can come from any fork of the repository, since GitHub serves them all from one place. Prefer one on the default branch or behind a release tag.",
213
+ summary: commitEvidence.length > 0 ? [commitEvidence.map(item => item.excerpt).join('; ').replace(/^./, char => char.toUpperCase())] : undefined,
214
+ },
215
+ commitEvidence,
216
+ 'info',
217
+ )
218
+ return [repository, commitRow]
219
+ }
@@ -32,7 +32,20 @@ export type RunOptions = {
32
32
  env?: Record<string, string>
33
33
  }
34
34
 
35
+ /** An HTTP answer, the body read as text. */
36
+ export type FetchResult = {
37
+ status: number
38
+ ok: boolean
39
+ /** Lower-cased names. */
40
+ headers: Record<string, string>
41
+ text: string
42
+ }
43
+
44
+ export type FetchInit = { method?: string; headers?: Record<string, string>; body?: string }
45
+
35
46
  export type Host = {
47
+ /** HTTPS requests, for repository metadata and the lookups you turn on. Absent where there is no network. */
48
+ fetch?: (url: string, init?: FetchInit) => Promise<FetchResult>
36
49
  list: (path: string) => Promise<HostEntry[]>
37
50
  stat: (path: string, options?: { resolve: boolean }) => Promise<HostStat>
38
51
  readBytes: (path: string) => Promise<Uint8Array>
@@ -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
+ }