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.
@@ -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
- /\b(?:security\s+)?(?:scanners?|reviewers?|auditors?|analy[sz]ers?|llms?|language\s+models?|ai\s+(?:assistants?|models?|reviewers?))\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,
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
- for (const match of findAll(text, REMOTE_INSTRUCTIONS, 3)) {
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
- return { ...base, status: 'pass', severity: 'none', evidence: [] }
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
+ }
@@ -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>