ai-install-guard 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +50 -0
- package/README.md +103 -24
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/gate/gate.ts +116 -0
- package/hooks/gate/recheck.ts +33 -0
- package/hooks/gate/requests.ts +728 -0
- package/hooks/gate/resolve.ts +210 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +44 -3
- package/hooks/guard/pane.tsx +200 -29
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +24 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +117 -75
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +120 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +427 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
|
@@ -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
|
+
}
|
|
@@ -20,6 +20,7 @@ export function structureChecks(ctx: ScanContext, validator: ValidatorReport | u
|
|
|
20
20
|
linksCheck(ctx),
|
|
21
21
|
imagesCheck(ctx),
|
|
22
22
|
settingsCheck(ctx),
|
|
23
|
+
filledInCheck(ctx),
|
|
23
24
|
]
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -355,3 +356,94 @@ function settingsCheck(ctx: ScanContext): CheckResult {
|
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
export type { InventoryFile }
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Files whose real content is not in the folder: Git LFS pointers, whose file
|
|
362
|
+
* is downloaded at install, and paths a git filter rewrites on checkout. The
|
|
363
|
+
* scan reads what is stored, so the code that runs could be anything.
|
|
364
|
+
*/
|
|
365
|
+
function filledInCheck(ctx: ScanContext): CheckResult {
|
|
366
|
+
const evidence: Evidence[] = []
|
|
367
|
+
for (const file of ctx.files) {
|
|
368
|
+
const name = basename(file.path)
|
|
369
|
+
if (name === '.gitattributes') {
|
|
370
|
+
// A line covers the files its pattern names under its own folder:
|
|
371
|
+
// rewriting only tests or docs is noted, live code is a warning.
|
|
372
|
+
const folder = dirname(file.path)
|
|
373
|
+
const lines = (ctx.text(file.path) ?? '').split('\n')
|
|
374
|
+
for (let index = 0; index < lines.length && index < 1000; index += 1) {
|
|
375
|
+
const line = (lines[index] as string).trim()
|
|
376
|
+
if (line.startsWith('#') || line.startsWith('[attr]')) continue
|
|
377
|
+
const driver = /(?:^|\s)filter=([^\s]{1,100})/.exec(line)?.[1]
|
|
378
|
+
if (driver === undefined) continue
|
|
379
|
+
const pattern = line.split(/\s/)[0] as string
|
|
380
|
+
const coversLive = ctx.files.some(other => {
|
|
381
|
+
const rel = folder === '' ? other.path : other.path.startsWith(`${folder}/`) ? other.path.slice(folder.length + 1) : undefined
|
|
382
|
+
return rel !== undefined && attributeMatches(pattern, rel) && isLive(ctx.roleOf(other.path))
|
|
383
|
+
})
|
|
384
|
+
evidence.push({ file: file.path, line: index + 1, excerpt: `git rewrites these files with the "${driver}" filter on checkout: ${line.slice(0, 200)}`, status: coversLive ? 'warn' : 'info' })
|
|
385
|
+
}
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (file.size > 1024) continue
|
|
389
|
+
const text = ctx.text(file.path)
|
|
390
|
+
if (text === undefined || !text.startsWith('version https://git-lfs.github.com/spec/')) continue
|
|
391
|
+
evidence.push({
|
|
392
|
+
file: file.path,
|
|
393
|
+
excerpt: 'a Git LFS pointer: the real file is downloaded when it is installed, and this scan could not read it',
|
|
394
|
+
// Images and docs often live in LFS; code that runs must not.
|
|
395
|
+
status: isLive(ctx.roleOf(file.path)) ? 'fail' : 'info',
|
|
396
|
+
})
|
|
397
|
+
if (evidence.length >= 20) break
|
|
398
|
+
}
|
|
399
|
+
return row(
|
|
400
|
+
{
|
|
401
|
+
id: 'structure.fetched',
|
|
402
|
+
group: GROUP,
|
|
403
|
+
title: 'Files filled in at install',
|
|
404
|
+
advice: 'The scan reads what the repository stores. A file that Git LFS downloads or a filter rewrites at install can hold different code, which was not checked.',
|
|
405
|
+
},
|
|
406
|
+
evidence,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Whether a .gitattributes pattern names `path` (relative to the attributes
|
|
412
|
+
* file's folder): no slash matches the name at any depth, a slash anchors it,
|
|
413
|
+
* `*` and `?` stay within a folder and `**` crosses them. A pattern too
|
|
414
|
+
* elaborate to match cheaply counts as matching, which can only warn more.
|
|
415
|
+
*/
|
|
416
|
+
export function attributeMatches(pattern: string, path: string): boolean {
|
|
417
|
+
if (pattern.endsWith('/')) return false
|
|
418
|
+
if (pattern.length > 200 || path.length > 1000 || (pattern.match(/[*?[]/g) ?? []).length > 8) return true
|
|
419
|
+
const anchored = pattern.includes('/')
|
|
420
|
+
const body = pattern.startsWith('/') ? pattern.slice(1) : pattern
|
|
421
|
+
let source = ''
|
|
422
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
423
|
+
const char = body[index] as string
|
|
424
|
+
if (char === '*' && body[index + 1] === '*') {
|
|
425
|
+
if (body[index + 2] === '/') {
|
|
426
|
+
source += '(?:.*/)?'
|
|
427
|
+
index += 2
|
|
428
|
+
} else {
|
|
429
|
+
source += '.*'
|
|
430
|
+
index += 1
|
|
431
|
+
}
|
|
432
|
+
} else if (char === '*') {
|
|
433
|
+
source += '[^/]*'
|
|
434
|
+
} else if (char === '?') {
|
|
435
|
+
source += '[^/]'
|
|
436
|
+
} else if (char === '[' && body.indexOf(']', index + 2) !== -1) {
|
|
437
|
+
const end = body.indexOf(']', index + 2)
|
|
438
|
+
source += `[${body.slice(index + 1, end).replace(/^!/, '^').replace(/[\\\]]/g, '\\$&')}]`
|
|
439
|
+
index = end
|
|
440
|
+
} else {
|
|
441
|
+
source += char.replace(/[.+^${}()|\\[\]]/g, '\\$&')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
return new RegExp(`^${source}$`).test(anchored ? path : basename(path))
|
|
446
|
+
} catch {
|
|
447
|
+
return true
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -50,6 +50,11 @@ export type CheckResult = {
|
|
|
50
50
|
severity: Severity
|
|
51
51
|
evidence: Evidence[]
|
|
52
52
|
advice: string
|
|
53
|
+
/**
|
|
54
|
+
* What the evidence means, in plain words for a person: a headline, then a
|
|
55
|
+
* few lines. Shown before the evidence, which stays one step further in.
|
|
56
|
+
*/
|
|
57
|
+
summary?: string[]
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
const RANK: Record<Status, number> = { info: 0, pass: 1, warn: 2, fail: 3 }
|
|
@@ -67,19 +72,30 @@ export function severityFor(status: Status): Severity {
|
|
|
67
72
|
return status === 'fail' ? 'high' : status === 'warn' ? 'medium' : 'none'
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
/**
|
|
76
|
+
* At most `max` pieces of evidence, the worst first, and a line counting the
|
|
77
|
+
* rest. Every piece is judged before capping, so a late failure still counts.
|
|
78
|
+
*/
|
|
79
|
+
export function capEvidence(evidence: Evidence[], max: number, fallback: Status = 'warn'): Evidence[] {
|
|
80
|
+
if (evidence.length <= max) return evidence
|
|
81
|
+
const worstFirst = [...evidence].sort((a, b) => RANK[b.status ?? fallback] - RANK[a.status ?? fallback])
|
|
82
|
+
return [...worstFirst.slice(0, max), { file: '-', excerpt: `and ${evidence.length - max} more`, status: 'info' }]
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
/**
|
|
71
86
|
* Builds a check's row from its evidence: the row takes the worst status any
|
|
72
87
|
* piece of evidence carries (`fallback` when none does), and `pass` when
|
|
73
88
|
* there is no evidence at all.
|
|
74
89
|
*/
|
|
75
90
|
export function row(
|
|
76
|
-
base: { id: string; group: GroupId; title: string; advice: string },
|
|
91
|
+
base: { id: string; group: GroupId; title: string; advice: string; summary?: string[] },
|
|
77
92
|
evidence: Evidence[],
|
|
78
93
|
fallback: Status = 'warn',
|
|
79
94
|
severity?: Severity,
|
|
80
95
|
): CheckResult {
|
|
81
96
|
if (evidence.length === 0) {
|
|
82
|
-
|
|
97
|
+
const { summary: _unused, ...rest } = base
|
|
98
|
+
return { ...rest, status: 'pass', severity: 'none', evidence: [] }
|
|
83
99
|
}
|
|
84
100
|
const status = evidence.reduce<Status>((acc, item) => worse(acc, item.status ?? fallback), 'info')
|
|
85
101
|
const worstFirst = [...evidence]
|
|
@@ -31,7 +31,7 @@ export async function runValidator(host: Host, root: string): Promise<ValidatorR
|
|
|
31
31
|
const reason = result.stderr.trim().split('\n')[0] ?? ''
|
|
32
32
|
return { ran: false, reason: reason === '' ? `exit code ${result.exitCode}` : reason.slice(0, 200) }
|
|
33
33
|
}
|
|
34
|
-
const report = parseValidatorReport(parsed.value)
|
|
34
|
+
const report = relativeTo(root, parseValidatorReport(parsed.value))
|
|
35
35
|
if (report.ran && parsed.value.success === false && report.errors.length === 0) {
|
|
36
36
|
report.errors.push({ file: '-', message: `claude plugin validate failed (exit code ${result.exitCode})` })
|
|
37
37
|
}
|
|
@@ -95,3 +95,17 @@ function splitList(text: string): string[] {
|
|
|
95
95
|
if (current.trim() !== '') out.push(current.trim())
|
|
96
96
|
return out
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The report with the scanned folder's own path taken out of file names and
|
|
101
|
+
* messages: a remote scan's folder is a temporary one, and every other place
|
|
102
|
+
* in the report is relative to the candidate.
|
|
103
|
+
*/
|
|
104
|
+
function relativeTo(root: string, report: ValidatorReport): ValidatorReport {
|
|
105
|
+
if (!report.ran) return report
|
|
106
|
+
const roots = [root, `/private${root}`].map(path => path.replace(/\/+$/, ''))
|
|
107
|
+
const strip = (text: string): string =>
|
|
108
|
+
roots.reduce((out, path) => out.split(`${path}/`).join('').split(path).join('.'), text)
|
|
109
|
+
const fix = (item: ValidatorProblem): ValidatorProblem => ({ file: strip(item.file), message: strip(item.message) })
|
|
110
|
+
return { ...report, errors: report.errors.map(fix), warnings: report.warnings.map(fix) }
|
|
111
|
+
}
|
|
@@ -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>
|