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
|
@@ -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>
|