ai-install-guard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +38 -0
- package/README.md +71 -10
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +12 -3
- package/hooks/guard/pane.tsx +54 -7
- package/hooks/guard/view.ts +19 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +77 -18
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +111 -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 +406 -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
package/hooks/scan/checks/mod.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { isLive, type ModuleInfo, type ModuleUse, type ScanContext } from './context'
|
|
2
|
+
import { describePrograms, type ProgramUse } from './programs'
|
|
3
|
+
import { spanKinds } from './text'
|
|
2
4
|
import { row, type CheckResult, type Evidence, type Status } from './types'
|
|
3
5
|
import type { ValidatorReport } from './validator'
|
|
4
6
|
|
|
@@ -105,28 +107,23 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
|
|
|
105
107
|
...dedupe(info.events).map(use => ({ use, kind: 'event' as const, capability: eventCapability(use) })),
|
|
106
108
|
...dedupe(info.calls).map(use => ({ use, kind: 'call' as const, capability: callCapability(use.name) })),
|
|
107
109
|
])
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
// A call is explained when the README (or the manifest's description)
|
|
111
|
+
// names it as a name of its own, not after a negation ("never calls
|
|
112
|
+
// process.run"), on a line or table row that says something about it: at
|
|
113
|
+
// least three words besides the name. A wildcard is never explained. The
|
|
114
|
+
// words are kept to quote beside the call, marked as the candidate's.
|
|
115
|
+
const sources: { where: Explanation['where']; text: string }[] = [
|
|
116
|
+
{ where: 'README', text: (ctx.readme?.text ?? '').replace(/<!--[\s\S]{0,20000}?-->/g, ' ') },
|
|
117
|
+
{ where: 'description', text: typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : '' },
|
|
118
|
+
]
|
|
119
|
+
const explanationCache = new Map<string, Explanation | undefined>()
|
|
120
|
+
const explanationFor = (name: string): Explanation | undefined => {
|
|
121
|
+
if (explanationCache.has(name)) return explanationCache.get(name)
|
|
122
|
+
const answer = findExplanation(name, sources)
|
|
123
|
+
explanationCache.set(name, answer)
|
|
116
124
|
return answer
|
|
117
125
|
}
|
|
118
|
-
const
|
|
119
|
-
if (name.length < 5 || !/^[a-z]+\.[a-zA-Z.]+$/.test(name)) return false
|
|
120
|
-
const escaped = name.replace(/\./g, '\\.')
|
|
121
|
-
const mention = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'gi')
|
|
122
|
-
// A mention right after a negation ("we never call process.run",
|
|
123
|
-
// "no process.run") says it does not use the call, so it explains nothing.
|
|
124
|
-
for (const found of explained.matchAll(mention)) {
|
|
125
|
-
const before = explained.slice(Math.max(0, (found.index ?? 0) - 40), found.index).split(/[.!?|\n]/).pop() ?? ''
|
|
126
|
-
if (!/\b(?:never|not|no|without|don['’]t|doesn['’]t|won['’]t|cannot|can['’]t)\b(?:\W+\w+){0,3}\W*$/i.test(before)) return true
|
|
127
|
-
}
|
|
128
|
-
return false
|
|
129
|
-
}
|
|
126
|
+
const isExplained = (name: string): boolean => explanationFor(name) !== undefined
|
|
130
127
|
|
|
131
128
|
const inventory: Evidence[] = uses.map(({ use, kind, capability }) => ({
|
|
132
129
|
file: use.file,
|
|
@@ -148,7 +145,8 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
|
|
|
148
145
|
status: explainedHere ? 'warn' : 'fail',
|
|
149
146
|
})
|
|
150
147
|
} else if (capability.risk === 'medium') {
|
|
151
|
-
|
|
148
|
+
// Still a warning when explained: network access always deserves a look.
|
|
149
|
+
medium.push({ file: use.file, line: use.line > 0 ? use.line : undefined, excerpt: isExplained(use.name) ? `${excerpt} (the README names it)` : excerpt, status: 'warn' })
|
|
152
150
|
}
|
|
153
151
|
}
|
|
154
152
|
const allCalls = new Set(modules.flatMap(info => info.calls.map(call => call.name)))
|
|
@@ -194,6 +192,7 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
|
|
|
194
192
|
group: GROUP,
|
|
195
193
|
title: 'High-risk capabilities',
|
|
196
194
|
advice: 'Running programs, writing files, deciding tool permissions, judging other plugins or changing the system prompt is what a mod needs to take over a session. Install only if its purpose needs it and its README says why.',
|
|
195
|
+
summary: capabilitySummary(uses.filter(item => item.capability.risk === 'high'), explanationFor, ctx, modules, allCalls.has('fs.read') && allCalls.has('http.fetch')),
|
|
197
196
|
},
|
|
198
197
|
high,
|
|
199
198
|
'fail',
|
|
@@ -204,6 +203,7 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
|
|
|
204
203
|
group: GROUP,
|
|
205
204
|
title: 'Network, environment and prompt access',
|
|
206
205
|
advice: 'Check every address it talks to and every variable it reads against what it says it does.',
|
|
206
|
+
summary: capabilitySummary(uses.filter(item => item.capability.risk === 'medium'), explanationFor, ctx, modules, false),
|
|
207
207
|
},
|
|
208
208
|
medium,
|
|
209
209
|
),
|
|
@@ -256,11 +256,6 @@ function eventCapability(use: ModuleUse): Capability {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/** Lowercased names the README or description spells out, such as `process.run`. */
|
|
259
|
-
function explanationsOf(ctx: ScanContext): string {
|
|
260
|
-
// Only what a reader sees: HTML comments are hidden when the README renders.
|
|
261
|
-
const visible = (ctx.readme?.text ?? '').replace(/<!--[\s\S]{0,20000}?-->/g, ' ')
|
|
262
|
-
return [visible, typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : ''].join('\n\n').toLowerCase()
|
|
263
|
-
}
|
|
264
259
|
|
|
265
260
|
/** Adds what Claude Code's own validator saw and the source scan missed. */
|
|
266
261
|
function withValidator(info: ModuleInfo, validator: ValidatorReport | undefined): ModuleInfo {
|
|
@@ -288,3 +283,92 @@ function dedupe(uses: readonly ModuleUse[]): ModuleUse[] {
|
|
|
288
283
|
}
|
|
289
284
|
|
|
290
285
|
export type { Status }
|
|
286
|
+
|
|
287
|
+
/** Where a call is explained, and the words that explain it. */
|
|
288
|
+
type Explanation = { where: 'README' | 'description'; quote: string }
|
|
289
|
+
|
|
290
|
+
const NEGATION = /\b(?:never|not|no|without|don['’]t|doesn['’]t|won['’]t|cannot|can['’]t)\b(?:\W+\w+){0,3}\W*$/i
|
|
291
|
+
|
|
292
|
+
function findExplanation(name: string, sources: readonly { where: Explanation['where']; text: string }[]): Explanation | undefined {
|
|
293
|
+
if (name.length < 5 || !/^[a-z]+\.[a-zA-Z.]+$/.test(name)) return undefined
|
|
294
|
+
const escaped = name.replace(/\./g, '\\.')
|
|
295
|
+
const mentions = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'gi')
|
|
296
|
+
const mention = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'i')
|
|
297
|
+
for (const { where, text } of sources) {
|
|
298
|
+
for (const line of text.split('\n')) {
|
|
299
|
+
for (const found of line.matchAll(mentions)) {
|
|
300
|
+
const index = found.index ?? 0
|
|
301
|
+
const before = line.slice(Math.max(0, index - 40), index).split(/[.!?|]/).pop() ?? ''
|
|
302
|
+
if (NEGATION.test(before)) continue
|
|
303
|
+
// A table row: every cell but the one that is only the call's name;
|
|
304
|
+
// a reason cell that repeats the name is still the reason.
|
|
305
|
+
const cells = line.trim().startsWith('|') ? line.split('|').map(cell => cell.trim()).filter(cell => cell !== '' && !/^:?-+:?$/.test(cell)) : [line]
|
|
306
|
+
const plain = (text: string) => text.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
|
|
307
|
+
const words = cells.filter(cell => plain(cell).toLowerCase() !== name.toLowerCase()).map(plain).join(' ')
|
|
308
|
+
const said = plain((cells.length > 1 ? words : line).replace(mentions, ''))
|
|
309
|
+
if ((said.match(/[A-Za-z]{3,}/g) ?? []).length < 3) continue
|
|
310
|
+
const quote = cells.length > 1 ? words : line.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
|
|
311
|
+
return { where, quote: quote.length > 220 ? `${quote.slice(0, 219)}…` : quote }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return undefined
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A capability row in plain words: what the mod can do and whether its
|
|
320
|
+
* README says why, the README's own words for it (marked as the
|
|
321
|
+
* candidate's), then the programs it starts when it runs any.
|
|
322
|
+
*/
|
|
323
|
+
function capabilitySummary(
|
|
324
|
+
uses: readonly { use: ModuleUse; kind: 'event' | 'call'; capability: Capability }[],
|
|
325
|
+
explanationFor: (name: string) => Explanation | undefined,
|
|
326
|
+
ctx: ScanContext,
|
|
327
|
+
modules: readonly ModuleInfo[],
|
|
328
|
+
readsAndSends: boolean,
|
|
329
|
+
): string[] | undefined {
|
|
330
|
+
const names = [...new Set(uses.map(item => item.use.name))]
|
|
331
|
+
if (names.length === 0) return undefined
|
|
332
|
+
const plain = [...new Set(uses.map(item => item.capability.plain))]
|
|
333
|
+
const told = names.every(name => explanationFor(name) !== undefined)
|
|
334
|
+
const lines = [`${plain.join('; ').replace(/^./, char => char.toUpperCase())}${told ? '; its README says why' : '; its README does not say why'}`]
|
|
335
|
+
if (readsAndSends) lines.push('Reads files and sends requests, so it could send your files out')
|
|
336
|
+
// The reasons come before the program list, so a long list cannot push them out of view.
|
|
337
|
+
for (const name of names.slice(0, 2)) {
|
|
338
|
+
const explanation = explanationFor(name)
|
|
339
|
+
if (explanation !== undefined) lines.push(`${explanation.where === 'README' ? 'The README says' : 'Its description says'} about ${name} (its words, not checked): "${explanation.quote}"`)
|
|
340
|
+
}
|
|
341
|
+
// Programs only for a mod that calls processes itself; hooking another's
|
|
342
|
+
// process.run starts nothing.
|
|
343
|
+
if (uses.some(item => item.kind === 'call' && /^process(?:\.|$)/.test(item.use.name))) {
|
|
344
|
+
// Only the modules that reach for processes, and the files they import.
|
|
345
|
+
const files = new Set(modules.filter(info => info.calls.some(call => /^process(?:\.|$)/.test(call.name))).flatMap(info => [info.entry, ...info.files]))
|
|
346
|
+
const described = describePrograms(programsOfModule(ctx, files), { buildsCode: 0, downloads: 0 }).filter(line => !line.startsWith('In '))
|
|
347
|
+
lines.push(...(described.length > 0 ? described.slice(0, 4) : ['Which programs it starts could not be read from its code']))
|
|
348
|
+
}
|
|
349
|
+
return lines
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The programs a mod's own files start with a literal name, as in
|
|
354
|
+
* `.run(['git', ...])` or `['run'](['git'])`, outside comments, patterns and
|
|
355
|
+
* strings. A name built while it runs (`${tool}`) is a program chosen then.
|
|
356
|
+
*/
|
|
357
|
+
function programsOfModule(ctx: ScanContext, files: ReadonlySet<string>): ProgramUse[] {
|
|
358
|
+
const uses: ProgramUse[] = []
|
|
359
|
+
for (const path of files) {
|
|
360
|
+
if (!/\.(?:[cm]?[jt]sx?)$/.test(path)) continue
|
|
361
|
+
const text = ctx.text(path)
|
|
362
|
+
if (text === undefined) continue
|
|
363
|
+
const kindAt = spanKinds(text, 'js')
|
|
364
|
+
for (const match of text.matchAll(/(?:\.(?:run|spawn)|\[\s*(["'])(?:run|spawn)\1\s*\])\s*\(\s*\[\s*(["'`])([^"'`\s]{1,100})\2/g)) {
|
|
365
|
+
const kind = kindAt(match.index ?? 0)
|
|
366
|
+
if (kind !== 'code') continue
|
|
367
|
+
const raw = match[3] as string
|
|
368
|
+
const dynamic = match[2] === '`' && raw.includes('${')
|
|
369
|
+
uses.push({ name: dynamic ? raw.slice(0, 60) : (raw.split(/[\\/]/).pop() ?? '').replace(/\.exe$/i, '').toLowerCase(), file: path, dynamic })
|
|
370
|
+
if (uses.length >= 500) return uses
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return uses
|
|
374
|
+
}
|
|
@@ -13,19 +13,20 @@ export const DOWNLOAD_AND_RUN =
|
|
|
13
13
|
/\b(?:curl|wget)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\b|\b(?:curl|wget)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:python[23]?|node|perl|ruby|php)\b|\b(?:ba|z)?sh\s+-c\s+["']?\$\(\s*(?:curl|wget)|(?:\b(?:source|(?:ba|z|da|k)?sh)|(?:^|(?<=[\s;&|(]))\.)\s+<\(\s*(?:curl|wget)|\bbase64\s+(?:-d|--decode|-D)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da)?sh\b|\bpython[23]?\s+-c\s+["'][^"'\n]{0,500}urllib|\beval\s+["']?\$\(\s*(?:curl|wget|echo|base64)/
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Fetching and running a package that is not pinned to an exact version
|
|
16
|
+
* Fetching and running a package that is not pinned to an exact version
|
|
17
|
+
* (npx, npm exec, pnpm dlx, yarn dlx, bunx, uvx, pipx run):
|
|
17
18
|
* no version, a tag (@latest, @next) or a range (@^1.2) all count. An exact
|
|
18
19
|
* x.y.z, with any prerelease (-rc-1) or build (+5) part, is a pin.
|
|
19
20
|
*/
|
|
20
21
|
export const UNPINNED_RUNNER =
|
|
21
|
-
/\b(?:npx|bunx|pnpx)\s+(?:-{1,2}[\w-]
|
|
22
|
+
/\b(?:npx|bunx|pnpx|npm\s+exec|pnpm\s+dlx|yarn\s+dlx|bun\s+x)\s+(?:-{1,2}[\w-]+(?:=\S+)?\s+){0,5}(?!-)(?:@[\w.-]+\/)?[\w.-]+(?:@(?!\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?(?![\w.+-]))[^\s`'"();&|]*)?(?![@\w./-])|\b(?:uvx|pipx\s+run)\s+(?!\S+(?:==|@)\d)/
|
|
22
23
|
|
|
23
24
|
/** Shell `eval` and piping into a shell. */
|
|
24
25
|
export const SHELL_EVAL = /(?:^|[\s;&|(])eval\s|\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\s*(?:$|[;&|)])/m
|
|
25
26
|
|
|
26
27
|
/** Places that hold credentials. */
|
|
27
28
|
export const CREDENTIAL_PATH =
|
|
28
|
-
/\.ssh(?:\/|\b)|\bid_(?:rsa|dsa|ecdsa|ed25519)\b|\.aws\/(?:credentials|config)\b|\.(?:netrc|git-credentials|pypirc)\b|\.npmrc\b|\.docker\/config\.json|\.kube\/config\b|\.config\/gh\/hosts|\.gnupg\b|\.claude\/\.credentials|Library\/Keychains|\bsecurity\s+(?:find|dump)-(?:generic|internet)-password|\bdump-keychain\b|\bLogin Data\b|Cookies\.binarycookies|\/Google\/Chrome
|
|
29
|
+
/\.ssh(?:\/|\b)|\bid_(?:rsa|dsa|ecdsa|ed25519)\b|\.aws\/(?:credentials|config)\b|\.(?:netrc|git-credentials|pypirc)\b|\.npmrc\b|\.docker\/config\.json|\.kube\/config\b|\.config\/gh\/hosts|\.gnupg\b|\.claude\/\.credentials|Library\/Keychains|\bsecurity\s+(?:find|dump)-(?:generic|internet)-password|\bdump-keychain\b|\bLogin Data\b|Cookies\.binarycookies|(?:Application Support|\.config)\/(?:Google\/Chrome|google-chrome|chromium|BraveSoftware|Microsoft Edge)\b|\/(?:Google\/Chrome|Chromium|BraveSoftware\/Brave-Browser|Microsoft\/Edge)\/User Data\b|\.mozilla\/firefox/
|
|
29
30
|
|
|
30
31
|
/** Environment variables that hold secrets, and dumps of the whole environment. */
|
|
31
32
|
export const SECRET_ENV =
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Turns "this code runs programs" into what a person can judge: which
|
|
2
|
+
// programs, what each one is for, and which deserve a closer look.
|
|
3
|
+
|
|
4
|
+
/** One place code starts a program. `dynamic` when the program is a variable. */
|
|
5
|
+
export type ProgramUse = { name: string; file: string; dynamic: boolean }
|
|
6
|
+
|
|
7
|
+
type Kind = { label: string; risky: boolean }
|
|
8
|
+
|
|
9
|
+
// Written as patterns, which the guard's own scan reads as inert, not as
|
|
10
|
+
// names of tools it runs.
|
|
11
|
+
// Written as patterns with an escape, which the guard's own scan reads as
|
|
12
|
+
// inert rather than as names of tools it runs.
|
|
13
|
+
const KINDS: readonly { match: RegExp; kind: Kind }[] = [
|
|
14
|
+
{ match: /^(?:sh|bash|zsh|dash|ksh|fish|csh|tcsh|cmd|powershell|pwsh)(?:\.exe)?$/, kind: { label: 'a shell, which can run any command', risky: true } },
|
|
15
|
+
{ match: /^(?:swiftc|swift|gcc|g\+\+|clang|clang\+\+|cc|c\+\+|rustc|cargo|go|javac|make|cmake|tsc|esbuild)(?:\.exe)?$/, kind: { label: 'builds code, which can then run', risky: true } },
|
|
16
|
+
{ match: /^(?:node|python|python3|deno|bun|ruby|perl|php|java)(?:\.exe)?$/, kind: { label: 'runs other scripts', risky: false } },
|
|
17
|
+
{ match: /^(?:curl|wget|ssh|scp|sftp|rsync|nc|ncat|netcat|socat|ftp|telnet)(?:\.exe)?$/, kind: { label: 'talks to the network', risky: true } },
|
|
18
|
+
{ match: /^(?:npm|npx|pnpm|pnpx|yarn|bunx|pip|pip3|pipx|uv|uvx|brew|apt|apt-get|gem)(?:\.exe)?$/, kind: { label: 'installs or fetches packages', risky: true } },
|
|
19
|
+
{ match: /^(?:sudo|doas|su)(?:\.exe)?$/, kind: { label: 'runs as administrator', risky: true } },
|
|
20
|
+
{ match: /^(?:security)(?:\.exe)?$/, kind: { label: 'reads the macOS keychain', risky: true } },
|
|
21
|
+
{ match: /^(?:osascript)(?:\.exe)?$/, kind: { label: 'controls other apps', risky: true } },
|
|
22
|
+
{ match: /^(?:launchctl|systemctl|crontab|schtasks)(?:\.exe)?$/, kind: { label: 'sets up background jobs', risky: true } },
|
|
23
|
+
{ match: /^(?:ffmpeg|ffprobe|ffplay|sox|magick|convert|identify|exiftool|gm|vips)(?:\.exe)?$/, kind: { label: 'video, audio and image tools', risky: false } },
|
|
24
|
+
{ match: /^(?:whisper|whisper-cli|whisper-cpp)(?:\.exe)?$/, kind: { label: 'speech to text', risky: false } },
|
|
25
|
+
{ match: /^(?:git|gh)(?:\.exe)?$/, kind: { label: 'git', risky: false } },
|
|
26
|
+
{ match: /^(?:rm|rmdir|del|shred)(?:\.exe)?$/, kind: { label: 'deletes files', risky: false } },
|
|
27
|
+
{
|
|
28
|
+
match: /^(?:cp|mv|mkdir|chmod|chown|ln|tar|unzip|zip|gzip|gunzip|touch|cat|ls|find|xargs|sed|awk|grep|head|tail|sort|which|du|df)$/,
|
|
29
|
+
kind: { label: 'file housekeeping', risky: false },
|
|
30
|
+
},
|
|
31
|
+
{ match: /^(?:open|xdg-open|start|explorer)(?:\.exe)?$/, kind: { label: 'opens files or links', risky: false } },
|
|
32
|
+
{ match: /^(?:chrome|google-chrome|chromium|chromium-browser|firefox|playwright)(?:\.exe)?$/, kind: { label: 'a web browser', risky: false } },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const KIND_OF = { get: (name: string): Kind | undefined => KINDS.find(entry => entry.match.test(name))?.kind }
|
|
36
|
+
|
|
37
|
+
const JS_CALLS =
|
|
38
|
+
/(?<![\w$.])(?:execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)\s*\(\s*(?:(["'`])([^"'`\n]{1,200})|([A-Za-z_$][\w$.]{0,80}))|\b(?:child_process|childProcess|cp)\.(execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)\s*\(\s*(?:(["'`])([^"'`\n]{1,200})|([A-Za-z_$][\w$.]{0,80}))|\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*(["'`])([^"'`\n]{1,200})|\bnew\s+Deno\.Command\s*\(\s*(["'`])([^"'`\n]{1,200})|\bDeno\.run\s*\(\s*\{\s*cmd\s*:\s*\[\s*(["'`])([^"'`\n]{1,200})/g
|
|
39
|
+
|
|
40
|
+
const PY_CALLS =
|
|
41
|
+
/\bsubprocess\.(?:run|call|Popen|check_output|check_call|getoutput|getstatusoutput)\s*\(\s*(?:\[\s*[rbfu]?(["'])([^"'\n]{1,200})|\[\s*([A-Za-z_][\w.]{0,80})|[rbfu]?(["'])([^"'\n]{1,200})|([A-Za-z_][\w.]{0,80}))|\bos\.(?:system|popen)\s*\(\s*[rbfu]?(["'])([^"'\n]{1,200})|\bpty\.spawn\s*\(\s*(?:\[\s*)?[rbfu]?(["'])([^"'\n]{1,200})|\bos\.(?:spawn|exec)[a-z]{0,4}\s*\(\s*(?:os\.P_\w+\s*,\s*)?(?:[rbfu]?(["'])([^"'\n]{1,200})|([A-Za-z_][\w.]{0,80}))/g
|
|
42
|
+
|
|
43
|
+
/** The programs a file starts, from its calls; `keep` drops matches in comments. */
|
|
44
|
+
export function programsIn(path: string, text: string, language: 'js' | 'python' | 'shell' | 'other', keep: (index: number) => boolean): ProgramUse[] {
|
|
45
|
+
const uses: ProgramUse[] = []
|
|
46
|
+
const patterns = language === 'js' ? [JS_CALLS] : language === 'python' ? [PY_CALLS] : language === 'shell' ? [] : [JS_CALLS, PY_CALLS]
|
|
47
|
+
for (const pattern of patterns) {
|
|
48
|
+
for (const match of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
|
|
49
|
+
if (uses.length >= 500) return uses
|
|
50
|
+
const index = match.index ?? 0
|
|
51
|
+
if (!keep(index)) continue
|
|
52
|
+
const groups = match.slice(1)
|
|
53
|
+
// The literal is the group after a quote; a variable has no quote before it.
|
|
54
|
+
let literal: string | undefined
|
|
55
|
+
let variable: string | undefined
|
|
56
|
+
for (let i = 0; i < groups.length; i += 1) {
|
|
57
|
+
const value = groups[i]
|
|
58
|
+
if (value === undefined) continue
|
|
59
|
+
if (/^["'`]$/.test(value)) {
|
|
60
|
+
literal = groups[i + 1]
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
if (/^(?:execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)$/.test(value)) continue
|
|
64
|
+
variable = value
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
if (literal !== undefined) {
|
|
68
|
+
// A shell command line (execSync, os.system, a string to subprocess)
|
|
69
|
+
// starts with its program; a list or execFile names the program alone.
|
|
70
|
+
const call = /(\w+)\s*\(\s*\[?\s*[rbfu]?["'`]?$/.exec(match[0].slice(0, match[0].length - literal.length))?.[1] ?? ''
|
|
71
|
+
const listForm = /\(\s*\[/.test(match[0])
|
|
72
|
+
const isLine = !listForm && (/^(?:execSync|exec|system|popen|getoutput|getstatusoutput)$/.test(call) || match[0].startsWith('subprocess.'))
|
|
73
|
+
const raw = (isLine ? literal.trim().split(/\s+/)[0] : literal) ?? ''
|
|
74
|
+
// A name built while it runs (`${tool}`, f"{tool}") is a program chosen at run time.
|
|
75
|
+
const fString = /[^\w]f["']$|^f["']/.test(match[0].slice(0, match[0].length - literal.length).slice(-2))
|
|
76
|
+
if (raw.includes('${') || (fString && raw.includes('{'))) {
|
|
77
|
+
uses.push({ name: raw.slice(0, 60), file: path, dynamic: true })
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const name = normalise(raw)
|
|
81
|
+
if (name !== '') uses.push({ name, file: path, dynamic: false })
|
|
82
|
+
} else if (variable !== undefined) {
|
|
83
|
+
// The running interpreter itself is node or python, not an unknown program.
|
|
84
|
+
const itself = variable === 'process.execPath' ? 'node' : variable === 'sys.executable' ? 'python' : undefined
|
|
85
|
+
uses.push({ name: itself ?? variable, file: path, dynamic: itself === undefined })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return uses
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalise(program: string): string {
|
|
93
|
+
const base = program.replace(/^["']|["']$/g, '').split(/[\\/]/).pop() ?? ''
|
|
94
|
+
return base.replace(/\.exe$/i, '').toLowerCase().slice(0, 60)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function plural(count: number, word: string): string {
|
|
98
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The plain-language lines for a row about running programs: a headline, then
|
|
103
|
+
* the programs worth a look, then the rest by what they are for.
|
|
104
|
+
*/
|
|
105
|
+
export function describePrograms(uses: readonly ProgramUse[], extra: { buildsCode: number; downloads: number }): string[] {
|
|
106
|
+
const counts = new Map<string, number>()
|
|
107
|
+
const files = new Set<string>()
|
|
108
|
+
const dynamic = new Map<string, number>()
|
|
109
|
+
for (const use of uses) {
|
|
110
|
+
files.add(use.file)
|
|
111
|
+
if (use.dynamic) dynamic.set(use.name, (dynamic.get(use.name) ?? 0) + 1)
|
|
112
|
+
else counts.set(use.name, (counts.get(use.name) ?? 0) + 1)
|
|
113
|
+
}
|
|
114
|
+
const byCount = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
115
|
+
const risky = byCount.filter(([name]) => KIND_OF.get(name)?.risky === true)
|
|
116
|
+
const ordered = [...risky, ...byCount.filter(([name]) => KIND_OF.get(name)?.risky !== true)]
|
|
117
|
+
|
|
118
|
+
const parts: string[] = []
|
|
119
|
+
if (extra.downloads > 0) parts.push('downloads code and runs it')
|
|
120
|
+
if (extra.buildsCode > 0) parts.push('builds code from text and runs it')
|
|
121
|
+
if (ordered.length > 0) {
|
|
122
|
+
const names = ordered.slice(0, 6).map(([name]) => name)
|
|
123
|
+
parts.push(`starts ${names.join(', ')}${ordered.length > 6 ? ` and ${ordered.length - 6} more` : ''}`)
|
|
124
|
+
}
|
|
125
|
+
if (dynamic.size > 0) parts.push('starts programs chosen while it runs')
|
|
126
|
+
if (parts.length === 0) return []
|
|
127
|
+
const headline = parts.join('; ')
|
|
128
|
+
const lines = [headline.charAt(0).toUpperCase() + headline.slice(1)]
|
|
129
|
+
|
|
130
|
+
const lookAt: string[] = []
|
|
131
|
+
if (extra.downloads > 0) lookAt.push(`downloads code and runs it (${plural(extra.downloads, 'place')})`)
|
|
132
|
+
if (extra.buildsCode > 0) lookAt.push(`builds code from text and runs it, as eval does (${plural(extra.buildsCode, 'place')})`)
|
|
133
|
+
for (const [name, count] of risky) lookAt.push(`${name}: ${KIND_OF.get(name)?.label} (${plural(count, 'place')})`)
|
|
134
|
+
if (dynamic.size > 0) lookAt.push(`a program named by ${[...dynamic.keys()].slice(0, 4).join(', ')}, chosen while it runs (${plural([...dynamic.values()].reduce((a, b) => a + b, 0), 'place')})`)
|
|
135
|
+
if (lookAt.length > 0) lines.push(`Worth a look: ${lookAt.join('; ')}`)
|
|
136
|
+
|
|
137
|
+
const groups = new Map<string, string[]>()
|
|
138
|
+
for (const [name, count] of byCount) {
|
|
139
|
+
const kind = KIND_OF.get(name)
|
|
140
|
+
if (kind?.risky === true) continue
|
|
141
|
+
const label = kind?.label ?? 'other programs'
|
|
142
|
+
groups.set(label, [...(groups.get(label) ?? []), `${name} (${count})`])
|
|
143
|
+
}
|
|
144
|
+
for (const [label, names] of groups) lines.push(`${label.charAt(0).toUpperCase()}${label.slice(1)}: ${names.join(', ')}`)
|
|
145
|
+
if (files.size > 0) {
|
|
146
|
+
const list = [...files].sort()
|
|
147
|
+
lines.push(`In ${plural(list.length, 'file')}: ${list.slice(0, 4).join(', ')}${list.length > 4 ? ` and ${list.length - 4} more` : ''}`)
|
|
148
|
+
}
|
|
149
|
+
return lines
|
|
150
|
+
}
|
|
@@ -25,7 +25,8 @@ const INJECTION: readonly RegExp[] = [
|
|
|
25
25
|
/** Text that talks to a security scanner instead of to a person. */
|
|
26
26
|
const SCANNER_ADDRESS: readonly RegExp[] = [
|
|
27
27
|
/\bnote\s+(?:to|for)\s+(?:the\s+|any\s+)?(?:security\s+|ai\s+|automated\s+)?(?:scanners?|reviewers?|auditors?|analy[sz]ers?)\b/i,
|
|
28
|
-
|
|
28
|
+
// "Reviewer" or "auditor" alone is usually a person; it counts only as a tool.
|
|
29
|
+
/\b(?:(?:security|ai|automated|static|code)\s+(?:scanners?|reviewers?|auditors?|analy[sz]ers?)|scanners?|analy[sz]ers?|llms?|language\s+models?|ai\s+(?:assistants?|models?))\b[^\n]{0,80}?\b(?:report|mark|classify|treat|consider|rate|flag|score)\b[^\n]{0,40}?\b(?:safe|pass(?:ed|ing)?|clean|benign|trusted|harmless|low[- ]risk|no\s+(?:issues|findings))\b/i,
|
|
29
30
|
/\b(?:this|the)\s+(?:plugin|skill|mod|code|package|repo(?:sitory)?|extension)\s+(?:is|has\s+been)\s+(?:completely\s+|totally\s+|fully\s+|100%\s+)?(?:safe|benign|harmless|verified|audited|approved)\b[^\n]{0,60}?\b(?:report|mark|pass|ignore|skip|approve)\b/i,
|
|
30
31
|
]
|
|
31
32
|
|
|
@@ -101,7 +102,9 @@ export function promptChecks(ctx: ScanContext): CheckResult[] {
|
|
|
101
102
|
credentials.push({ file: path, line: match.line, excerpt: `names ${secretName(match)}`, status: cap(/\b(?:echo|print|printenv|cat|send|include|output)\b/i.test(lineText) ? 'fail' : 'warn') })
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
// A local address (a preview server the skill starts) is not remote.
|
|
106
|
+
const remoteOnly = (index: number) => !/^[^\n]{0,120}?https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|\[::1\]|0\.0\.0\.0)(?![\w.-])/i.test(text.slice(index, index + 200))
|
|
107
|
+
for (const match of findAll(text, REMOTE_INSTRUCTIONS, 3, remoteOnly)) {
|
|
105
108
|
remote.push({ file: path, line: match.line, excerpt: match.excerpt, status: cap('warn') })
|
|
106
109
|
}
|
|
107
110
|
// Telling the model to download and run code fails; a sentence warning
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { isRecord, parseJson, type ScanContext } from './context'
|
|
2
|
+
import { joinPath } from './text'
|
|
3
|
+
import { capEvidence, row, type CheckResult, type Evidence } from './types'
|
|
4
|
+
|
|
5
|
+
const GROUP = 'provenance' as const
|
|
6
|
+
const MARKETPLACE = '.claude-plugin/marketplace.json'
|
|
7
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
|
|
8
|
+
const SPECIAL_REFS = /^(?:HEAD|FETCH_HEAD|ORIG_HEAD|MERGE_HEAD|CHERRY_PICK_HEAD|REVERT_HEAD|REBASE_HEAD|BISECT_HEAD|AUTO_MERGE)$/
|
|
9
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Group 1, for a marketplace: whether each plugin it lists from elsewhere is
|
|
13
|
+
* pinned to one commit (or one package version). A plugin that follows a
|
|
14
|
+
* branch installs whatever the branch holds that day, not what was reviewed.
|
|
15
|
+
*/
|
|
16
|
+
export function marketplacePinsCheck(ctx: ScanContext): CheckResult[] {
|
|
17
|
+
const parsed = parseJson(ctx.text(MARKETPLACE))
|
|
18
|
+
if (!parsed.ok || !isRecord(parsed.value) || !Array.isArray(parsed.value.plugins)) return []
|
|
19
|
+
const evidence: Evidence[] = []
|
|
20
|
+
for (const plugin of parsed.value.plugins) {
|
|
21
|
+
if (!isRecord(plugin)) continue
|
|
22
|
+
const name = typeof plugin.name === 'string' ? plugin.name : '(unnamed)'
|
|
23
|
+
const judged = judgeSource(plugin.source)
|
|
24
|
+
if (judged !== undefined) evidence.push({ file: MARKETPLACE, excerpt: `${name}: ${judged.what}`, status: judged.status })
|
|
25
|
+
}
|
|
26
|
+
if (evidence.length === 0) return []
|
|
27
|
+
return [
|
|
28
|
+
row(
|
|
29
|
+
{
|
|
30
|
+
id: 'provenance.pins',
|
|
31
|
+
group: GROUP,
|
|
32
|
+
title: 'Marketplace pins',
|
|
33
|
+
advice: 'Pin each plugin to a full commit id (sha) or an exact package version. A branch, a tag or a commit given as ref can change what installs after it was reviewed.',
|
|
34
|
+
},
|
|
35
|
+
// Every entry is judged; only the worst are listed.
|
|
36
|
+
capEvidence(evidence, 30),
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function judgeSource(source: unknown): { what: string; status: 'pass' | 'warn' | 'fail' } | undefined {
|
|
42
|
+
if (typeof source === 'string') {
|
|
43
|
+
// A folder in the marketplace is pinned with the marketplace itself; one
|
|
44
|
+
// that climbs out of it is not part of what was pinned.
|
|
45
|
+
if (source.startsWith('./') || source.startsWith('../')) {
|
|
46
|
+
return joinPath('', source) === undefined ? { what: `comes from ${source}, outside the marketplace, which is not pinned with it`, status: 'warn' } : undefined
|
|
47
|
+
}
|
|
48
|
+
return { what: `comes from ${source} and follows its default branch`, status: 'warn' }
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(source)) return undefined
|
|
51
|
+
const kind = typeof source.source === 'string' ? source.source : 'unknown'
|
|
52
|
+
if (kind === 'npm' || kind === 'pip') {
|
|
53
|
+
const version = typeof source.version === 'string' ? source.version : undefined
|
|
54
|
+
const pkg = typeof source.package === 'string' ? source.package : '(package)'
|
|
55
|
+
return version !== undefined && EXACT_VERSION.test(version)
|
|
56
|
+
? { what: `${kind} package ${pkg} at exactly ${version}`, status: 'pass' }
|
|
57
|
+
: { what: `${kind} package ${pkg} ${version === undefined ? 'at whatever version is newest' : `at ${version}, not one exact version`}`, status: 'warn' }
|
|
58
|
+
}
|
|
59
|
+
const where = typeof source.repo === 'string' ? source.repo : typeof source.url === 'string' ? source.url : kind
|
|
60
|
+
const sha = typeof source.sha === 'string' ? source.sha : undefined
|
|
61
|
+
const ref = typeof source.ref === 'string' ? source.ref : undefined
|
|
62
|
+
if (ref !== undefined && SPECIAL_REFS.test(ref)) return { what: `${where} names git's own ${ref} as its ref`, status: 'fail' }
|
|
63
|
+
if (sha !== undefined) {
|
|
64
|
+
return FULL_SHA.test(sha)
|
|
65
|
+
? { what: `${where} pinned to commit ${sha.slice(0, 12)}`, status: 'pass' }
|
|
66
|
+
: { what: `${where} has sha "${sha.slice(0, 64)}", which is not a full commit id`, status: 'warn' }
|
|
67
|
+
}
|
|
68
|
+
if (ref !== undefined && FULL_SHA.test(ref)) {
|
|
69
|
+
return { what: `${where} gives a commit id as its ref; a branch with that name would be checked out instead, so use sha`, status: 'warn' }
|
|
70
|
+
}
|
|
71
|
+
if (ref !== undefined) return { what: `${where} follows ${ref}, which its owner can move`, status: 'warn' }
|
|
72
|
+
return { what: `${where} follows its default branch`, status: 'warn' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a README's AI Install Guard badge claims, read from its shields.io image. */
|
|
76
|
+
export type BadgeClaim = {
|
|
77
|
+
file: string
|
|
78
|
+
line: number
|
|
79
|
+
claimed: 'safe' | 'caution' | 'reject' | 'unverified'
|
|
80
|
+
/** The version the badge names: a short commit or `tree <hash>`, when it names one. */
|
|
81
|
+
pin?: string
|
|
82
|
+
text: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const BADGE_IMAGE = /img\.shields\.io\/badge\/AI(?:_|%20| )Install(?:_|%20| )Guard-([^)\s"'?]{1,300})/gi
|
|
86
|
+
const HTML_BLOCK_START = /^ {0,3}<\/?[A-Za-z][\w-]*(?:\s|\/?>|$)/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The strongest claim among the AI Install Guard badges a README shows, read
|
|
90
|
+
* the way a Markdown renderer shows it: fenced and indented code blocks and
|
|
91
|
+
* HTML comments are left out (there a badge is an example or hidden), while
|
|
92
|
+
* HTML blocks and list items, where badges usually sit indented, count. A
|
|
93
|
+
* badge whose words name no verdict is read as claiming the best one, since
|
|
94
|
+
* that is what it would make a reader think.
|
|
95
|
+
*/
|
|
96
|
+
export function badgeClaim(readme: { path: string; text: string } | undefined): BadgeClaim | undefined {
|
|
97
|
+
if (readme === undefined) return undefined
|
|
98
|
+
const lines = readme.text.split('\n')
|
|
99
|
+
let fence: { char: string; length: number } | undefined
|
|
100
|
+
let comment = false
|
|
101
|
+
let htmlBlock = false
|
|
102
|
+
let indentedCode = false
|
|
103
|
+
let listContext = false
|
|
104
|
+
let previousBlank = true
|
|
105
|
+
let best: BadgeClaim | undefined
|
|
106
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
107
|
+
let line = lines[index] as string
|
|
108
|
+
const blank = line.trim() === ''
|
|
109
|
+
// Inside a list item a fence may sit as deep as the item's own content.
|
|
110
|
+
const fenceIndent = listContext ? '[ \\t]{0,16}' : ' {0,3}'
|
|
111
|
+
if (fence !== undefined) {
|
|
112
|
+
// A fence closes only with its own character, at least as long, and nothing after.
|
|
113
|
+
const close = new RegExp(`^${fenceIndent}(\`{3,}|~{3,})[ \\t]*$`).exec(line)
|
|
114
|
+
if (close !== null && (close[1] as string)[0] === fence.char && (close[1] as string).length >= fence.length) fence = undefined
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (comment) {
|
|
118
|
+
const end = line.indexOf('-->')
|
|
119
|
+
if (end === -1) continue
|
|
120
|
+
comment = false
|
|
121
|
+
line = line.slice(end + 3)
|
|
122
|
+
}
|
|
123
|
+
const open = htmlBlock ? null : new RegExp(`^${fenceIndent}(\`{3,}|~{3,})(.*)$`).exec(line)
|
|
124
|
+
// A backtick fence's info string cannot hold a backtick: ```x``` is inline code.
|
|
125
|
+
if (open !== null && !((open[1] as string)[0] === '`' && (open[2] as string).includes('`'))) {
|
|
126
|
+
fence = { char: (open[1] as string)[0] as string, length: (open[1] as string).length }
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
// An HTML comment hides what it holds only when it opens a line; inline,
|
|
130
|
+
// only a closed one is a comment.
|
|
131
|
+
if (/^ {0,3}<!--/.test(line) && !line.includes('-->', line.indexOf('<!--') + 4)) {
|
|
132
|
+
comment = true
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const indented = /^(?: {4}|\t)/.test(line)
|
|
136
|
+
if (indentedCode && !blank && !indented) indentedCode = false
|
|
137
|
+
if (!indentedCode && indented && previousBlank && !listContext && !htmlBlock) indentedCode = true
|
|
138
|
+
if (htmlBlock && blank) htmlBlock = false
|
|
139
|
+
else if (!htmlBlock && !indentedCode && HTML_BLOCK_START.test(line)) htmlBlock = true
|
|
140
|
+
if (!blank && !indented) listContext = /^ {0,3}(?:[-*+]|\d{1,9}[.)])\s/.test(line) || (listContext && !previousBlank)
|
|
141
|
+
else if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])\s/.test(line)) listContext = true
|
|
142
|
+
previousBlank = blank
|
|
143
|
+
if (indentedCode) continue
|
|
144
|
+
const shown = line.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`]*`/g, '')
|
|
145
|
+
for (const found of shown.matchAll(BADGE_IMAGE)) {
|
|
146
|
+
let message = found[1] as string
|
|
147
|
+
try {
|
|
148
|
+
message = decodeURIComponent(message)
|
|
149
|
+
} catch {
|
|
150
|
+
// Left as written.
|
|
151
|
+
}
|
|
152
|
+
const words = message.replace(/--/g, '\u0000').replace(/-[0-9a-f]{3,8}$|-[a-z]+$/i, '').replace(/_/g, ' ').replace(/\u0000/g, '-').toLowerCase()
|
|
153
|
+
const claimed = /do not install/.test(words) ? 'reject' : /could not verify/.test(words) ? 'unverified' : /caution|review what it can do/.test(words) ? 'caution' : 'safe'
|
|
154
|
+
const pin = /·\s*((?:tree\s+)?[0-9a-f]{7,64})\b/.exec(words)?.[1]
|
|
155
|
+
if (best === undefined || VERDICT_RANK[claimed] < VERDICT_RANK[best.claimed]) best = { file: readme.path, line: index + 1, claimed, pin, text: words.slice(0, 120) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return best
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const VERDICT_RANK = { safe: 0, caution: 1, unverified: 2, reject: 3 } as const
|
|
162
|
+
const VERDICT_WORDS = { safe: 'Safe to install', caution: 'Review what it can do', unverified: 'Could not verify', reject: 'Do not install' } as const
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The row for a README's badge, judged against this scan's own verdict. A
|
|
166
|
+
* badge never improves a verdict: one that claims better than the scan finds
|
|
167
|
+
* is a warning, one that matches is only noted.
|
|
168
|
+
*/
|
|
169
|
+
export function badgeRow(claim: BadgeClaim, actual: keyof typeof VERDICT_RANK, version: { sha?: string; treeHash?: string } = {}): CheckResult {
|
|
170
|
+
const overclaims = VERDICT_RANK[claim.claimed] < VERDICT_RANK[actual]
|
|
171
|
+
// Which version the badge names, against the one scanned now.
|
|
172
|
+
const scanned = version.sha !== undefined ? version.sha.slice(0, 7) : version.treeHash !== undefined ? `tree ${version.treeHash.slice(7, 15)}` : undefined
|
|
173
|
+
const sameVersion = claim.pin !== undefined && scanned !== undefined && (claim.pin.startsWith('tree ') ? version.treeHash?.slice(7).startsWith(claim.pin.slice(5)) === true : version.sha?.startsWith(claim.pin) === true)
|
|
174
|
+
const named = claim.pin === undefined ? 'names no version' : sameVersion ? `is for this version (${claim.pin})` : `is for another version (${claim.pin})`
|
|
175
|
+
const summary = overclaims
|
|
176
|
+
? [`The README's badge says ${VERDICT_WORDS[claim.claimed]}, but this version scans as ${VERDICT_WORDS[actual]}`, `The badge ${named}: it is outdated or was never earned`]
|
|
177
|
+
: sameVersion && claim.claimed === actual
|
|
178
|
+
? [`The README shows an AI Install Guard badge for this version (${VERDICT_WORDS[claim.claimed]}); this scan agrees`]
|
|
179
|
+
: [`The README's badge says ${VERDICT_WORDS[claim.claimed]} and ${named}; this version scans as ${VERDICT_WORDS[actual]}`]
|
|
180
|
+
return row(
|
|
181
|
+
{
|
|
182
|
+
id: 'provenance.badge',
|
|
183
|
+
group: GROUP,
|
|
184
|
+
title: 'Review badge in the README',
|
|
185
|
+
advice: 'A badge is a picture anyone can paste. This scan is what counts: a badge that claims more than it finds is outdated or was never earned.',
|
|
186
|
+
summary,
|
|
187
|
+
},
|
|
188
|
+
[{ file: claim.file, line: claim.line, excerpt: `badge: ${claim.text}`, status: overclaims ? 'warn' : 'info' }],
|
|
189
|
+
)
|
|
190
|
+
}
|