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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- 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 +406 -0
- package/hooks/scan/report.ts +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -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
|
+
}
|
package/hooks/scan/host.ts
CHANGED
|
@@ -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
|
+
}
|