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
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// Fetching a remote candidate without running any of it: the ref is resolved
|
|
2
|
+
// to one commit, only that commit is fetched into a folder the guard owns,
|
|
3
|
+
// and the checkout is confirmed to have landed on it. The checks then read
|
|
4
|
+
// that folder like any local one, and the folder is removed afterwards.
|
|
5
|
+
|
|
6
|
+
import { capEvidence, row, type CheckResult, type Evidence } from './checks/types'
|
|
7
|
+
import type { Host, RunResult } from './host'
|
|
8
|
+
import { LIMITS } from './inventory'
|
|
9
|
+
import { safeLine } from './safe-text'
|
|
10
|
+
|
|
11
|
+
/** A remote candidate as asked for: an https git URL, a ref and a folder in it. */
|
|
12
|
+
export type RemoteSpec = {
|
|
13
|
+
url: string
|
|
14
|
+
ref?: string
|
|
15
|
+
subdir?: string
|
|
16
|
+
/**
|
|
17
|
+
* The part of a GitHub link after /tree/: a ref, which may itself hold
|
|
18
|
+
* slashes, then a folder. Split once the repository's refs are known.
|
|
19
|
+
*/
|
|
20
|
+
treePath?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What was actually scanned: one commit, named however it was asked for. */
|
|
24
|
+
export type PinnedSource = {
|
|
25
|
+
url: string
|
|
26
|
+
/** The ref as asked for; absent for the default branch. */
|
|
27
|
+
ref?: string
|
|
28
|
+
refKind: 'commit' | 'tag' | 'branch' | 'default'
|
|
29
|
+
sha: string
|
|
30
|
+
/** The branch the repository's HEAD names, when it says. */
|
|
31
|
+
defaultBranch?: string
|
|
32
|
+
subdir?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type FetchedSource = {
|
|
36
|
+
root: string
|
|
37
|
+
source: PinnedSource
|
|
38
|
+
/** Rows about the source itself: what was pinned and how its refs look. */
|
|
39
|
+
results: CheckResult[]
|
|
40
|
+
cleanup: () => Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The most a fetched commit may weigh before the scan gives up. */
|
|
44
|
+
export const MAX_FETCH_BYTES = 50 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
/** How long a fetch may take. */
|
|
47
|
+
const FETCH_TIMEOUT_MS = 120_000
|
|
48
|
+
|
|
49
|
+
/** The same limit in the 512-byte blocks a shell's `ulimit -f` counts. */
|
|
50
|
+
const FETCH_LIMIT_BLOCKS = Math.ceil(MAX_FETCH_BYTES / 512)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs the fetch under a file-size limit, so a commit far over the limit is
|
|
54
|
+
* stopped as it arrives rather than measured after it has filled the disk.
|
|
55
|
+
* fetch.unpackLimit=1 keeps everything in the one pack the limit applies to.
|
|
56
|
+
* Where there is no `sh` (Windows), the fetch is bounded by its time alone.
|
|
57
|
+
*/
|
|
58
|
+
async function boundedFetch(host: Host, args: readonly string[]): Promise<RunResult> {
|
|
59
|
+
// Whether there is a shell to set the limit is asked first, so a capped
|
|
60
|
+
// fetch that fails or times out is never run again without its cap.
|
|
61
|
+
const shell = await host.run(['sh', '-c', 'exit 0'], { timeoutMs: 10_000 }).catch(() => undefined)
|
|
62
|
+
if (shell === undefined || shell.exitCode !== 0) return git(host, args, FETCH_TIMEOUT_MS)
|
|
63
|
+
return host.run(['sh', '-c', `ulimit -f ${FETCH_LIMIT_BLOCKS} && exec git "$@"`, 'sh', ...args], { timeoutMs: FETCH_TIMEOUT_MS, env: GIT_ENV })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** No prompts for credentials, and no Git LFS downloads during checkout. */
|
|
67
|
+
const GIT_ENV: Record<string, string> = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_LFS_SKIP_SMUDGE: '1' }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* For the steps that touch only the local copy: none of your own git
|
|
71
|
+
* configuration or attributes, so a `.gitattributes` in the candidate cannot
|
|
72
|
+
* select a filter program you have configured. The network steps keep your
|
|
73
|
+
* configuration for proxies and certificates.
|
|
74
|
+
*/
|
|
75
|
+
const ISOLATED_ENV: Record<string, string> = { ...GIT_ENV, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1' }
|
|
76
|
+
|
|
77
|
+
/** Names git gives its own refs; a branch or tag with one is a trap. */
|
|
78
|
+
const SPECIAL_REFS = new Set(['HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD', 'BISECT_HEAD', 'AUTO_MERGE'])
|
|
79
|
+
|
|
80
|
+
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
81
|
+
const SHORT_SHA = /^[0-9a-f]{7,39}$/
|
|
82
|
+
const SHORTHAND = /^(?:github:)?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?(?:@([^\s@]{1,250}))?$/
|
|
83
|
+
|
|
84
|
+
/** Whether `target` is written as a remote source rather than a folder. */
|
|
85
|
+
export function looksRemote(target: string): 'remote' | 'maybe' | 'local' {
|
|
86
|
+
if (/^(?:[a-z][a-z0-9+.-]*:\/\/|git@|github:)/i.test(target)) return 'remote'
|
|
87
|
+
// owner/repo could also be a folder: the caller checks the disk first.
|
|
88
|
+
return SHORTHAND.test(target) ? 'maybe' : 'local'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads a remote source: `https://host/path`, a GitHub tree URL
|
|
93
|
+
* (`https://github.com/o/r/tree/<ref>/<folder>`), `github:o/r` or `o/r`, each
|
|
94
|
+
* with an optional `@ref`. Only https is fetched, and never with a password
|
|
95
|
+
* in the URL. `ref` and `subdir` from options win over the text's own.
|
|
96
|
+
*/
|
|
97
|
+
export function parseRemote(target: string, options: { ref?: string; subdir?: string } = {}): RemoteSpec {
|
|
98
|
+
const text = target.trim()
|
|
99
|
+
let spec: RemoteSpec
|
|
100
|
+
const shorthand = SHORTHAND.exec(text)
|
|
101
|
+
if (shorthand !== null && !text.includes('://')) {
|
|
102
|
+
spec = { url: `https://github.com/${shorthand[1]}/${shorthand[2]}.git`, ref: shorthand[3] }
|
|
103
|
+
} else {
|
|
104
|
+
let url: URL
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(text)
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`"${safeLine(text)}" is not a folder or a repository address`)
|
|
109
|
+
}
|
|
110
|
+
if (url.protocol !== 'https:') throw new Error('only https:// sources are fetched')
|
|
111
|
+
if (url.username !== '' || url.password !== '') throw new Error('a source address must not carry a user name or password')
|
|
112
|
+
const parts = url.pathname.split('/').filter(part => part !== '')
|
|
113
|
+
if (url.hostname.toLowerCase() === 'github.com' && parts.length >= 2) {
|
|
114
|
+
const repo = (parts[1] as string).replace(/\.git$/, '')
|
|
115
|
+
spec = { url: `https://github.com/${parts[0]}/${repo}.git` }
|
|
116
|
+
if (parts[2] === 'tree' && parts[3] !== undefined) spec.treePath = parts.slice(3).map(decodeURIComponent).join('/')
|
|
117
|
+
} else {
|
|
118
|
+
spec = { url: `${url.origin}${url.pathname}` }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// A ref named outright replaces the link's own ref and folder.
|
|
122
|
+
if (options.ref !== undefined) {
|
|
123
|
+
spec.ref = options.ref
|
|
124
|
+
delete spec.treePath
|
|
125
|
+
}
|
|
126
|
+
if (options.subdir !== undefined) spec.subdir = options.subdir
|
|
127
|
+
if (spec.subdir !== undefined) spec.subdir = safeSubdir(spec.subdir)
|
|
128
|
+
return spec
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A folder inside the repository: relative, with no `..`, normalised. */
|
|
132
|
+
function safeSubdir(subdir: string): string | undefined {
|
|
133
|
+
const parts = subdir.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
|
|
134
|
+
if (/^[\\/]|^[A-Za-z]:/.test(subdir) || parts.includes('..')) throw new Error(`the folder "${safeLine(subdir)}" is not inside the repository`)
|
|
135
|
+
return parts.length === 0 ? undefined : parts.join('/')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Splits a GitHub link's `<ref>/<folder>` at the longest leading part that
|
|
140
|
+
* names a branch or tag, so a branch called feature/foo is not read as
|
|
141
|
+
* branch feature, folder foo.
|
|
142
|
+
*/
|
|
143
|
+
export function splitTreePath(listed: RemoteRefs, treePath: string): { ref: string; subdir?: string } {
|
|
144
|
+
const parts = treePath.split('/').filter(part => part !== '')
|
|
145
|
+
for (let count = parts.length; count >= 1; count -= 1) {
|
|
146
|
+
const ref = parts.slice(0, count).join('/')
|
|
147
|
+
const known = listed.refs.has(`refs/heads/${ref}`) || listed.refs.has(`refs/tags/${ref}`) || (count === 1 && FULL_SHA.test(ref.toLowerCase()))
|
|
148
|
+
if (known) return { ref, subdir: count < parts.length ? safeSubdir(parts.slice(count).join('/')) : undefined }
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`no branch or tag of the repository matches the link's "${safeLine(treePath)}"`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Why a ref name cannot be fetched safely, or undefined when it can. */
|
|
154
|
+
function unsafeRef(ref: string): string | undefined {
|
|
155
|
+
if (ref.startsWith('-')) return 'starts with a dash'
|
|
156
|
+
if (SPECIAL_REFS.has(ref)) return `is git's own ${ref}`
|
|
157
|
+
if (/[\x00-\x20\x7f~^:?*[\\]|\.\.|@\{|\/\/|^\/|\/$|\.lock$|^\.|\/\./.test(ref)) return 'is not a valid ref name'
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type RemoteRefs = {
|
|
162
|
+
head?: string
|
|
163
|
+
defaultBranch?: string
|
|
164
|
+
/** Full ref name to commit: a tag's own commit when the tag is annotated. */
|
|
165
|
+
refs: Map<string, string>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function parseLsRemote(stdout: string): RemoteRefs {
|
|
169
|
+
const refs = new Map<string, string>()
|
|
170
|
+
const peeled = new Map<string, string>()
|
|
171
|
+
let head: string | undefined
|
|
172
|
+
let defaultBranch: string | undefined
|
|
173
|
+
for (const line of stdout.split('\n')) {
|
|
174
|
+
const symref = /^ref: (\S+)\tHEAD$/.exec(line)
|
|
175
|
+
if (symref !== null) {
|
|
176
|
+
defaultBranch = symref[1]
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
const entry = /^([0-9a-f]{40}|[0-9a-f]{64})\t(\S+)$/.exec(line)
|
|
180
|
+
if (entry === null) continue
|
|
181
|
+
const [, sha, name] = entry as unknown as [string, string, string]
|
|
182
|
+
if (name === 'HEAD') head = sha
|
|
183
|
+
else if (name.endsWith('^{}')) peeled.set(name.slice(0, -3), sha)
|
|
184
|
+
else refs.set(name, sha)
|
|
185
|
+
}
|
|
186
|
+
for (const [name, sha] of peeled) refs.set(name, sha)
|
|
187
|
+
return { head, defaultBranch, refs }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The commit a ref names, refusing anything a name could make ambiguous. */
|
|
191
|
+
export function resolveRef(listed: RemoteRefs, ref: string | undefined): Pick<PinnedSource, 'sha' | 'refKind' | 'ref'> {
|
|
192
|
+
if (ref === undefined) {
|
|
193
|
+
if (listed.head === undefined) throw new Error('the repository has no default branch to scan')
|
|
194
|
+
return { sha: listed.head, refKind: 'default' }
|
|
195
|
+
}
|
|
196
|
+
const lower = ref.toLowerCase()
|
|
197
|
+
if (FULL_SHA.test(lower)) return { sha: lower, refKind: 'commit', ref }
|
|
198
|
+
const bad = unsafeRef(ref)
|
|
199
|
+
if (bad !== undefined) throw new Error(`the ref "${safeLine(ref)}" ${bad}`)
|
|
200
|
+
const names = ref.startsWith('refs/') ? [ref] : [`refs/tags/${ref}`, `refs/heads/${ref}`]
|
|
201
|
+
const found = names.filter(name => listed.refs.has(name))
|
|
202
|
+
if (found.length > 1) throw new Error(`both a branch and a tag are named "${safeLine(ref)}"; name the commit instead`)
|
|
203
|
+
const name = found[0]
|
|
204
|
+
if (name === undefined) {
|
|
205
|
+
if (SHORT_SHA.test(lower)) throw new Error(`"${safeLine(ref)}" looks like a short commit id; use the full 40-character id`)
|
|
206
|
+
throw new Error(`the repository has no branch or tag named "${safeLine(ref)}"`)
|
|
207
|
+
}
|
|
208
|
+
return { sha: listed.refs.get(name) as string, refKind: name.startsWith('refs/tags/') ? 'tag' : 'branch', ref }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Fetches exactly one commit into a new folder of the guard's own and checks
|
|
213
|
+
* it out, with no hooks, no submodules, no LFS downloads and no filters. The
|
|
214
|
+
* checkout is asserted to sit on the pinned commit: a branch named like the
|
|
215
|
+
* commit, or like FETCH_HEAD, cannot swap other code in.
|
|
216
|
+
*/
|
|
217
|
+
export async function fetchPinned(host: Host, spec: RemoteSpec, onStage?: (stage: string) => Promise<void> | void): Promise<FetchedSource> {
|
|
218
|
+
await onStage?.(`resolving ${safeLine(spec.url)}`)
|
|
219
|
+
const listing = await git(host, ['ls-remote', '--symref', spec.url, 'HEAD', 'refs/heads/*', 'refs/tags/*'], 60_000)
|
|
220
|
+
if (listing.exitCode !== 0) throw failure(listing, `could not reach ${spec.url}`)
|
|
221
|
+
const listed = parseLsRemote(listing.stdout)
|
|
222
|
+
const fromLink = spec.treePath !== undefined && spec.ref === undefined ? splitTreePath(listed, spec.treePath) : undefined
|
|
223
|
+
const subdir = spec.subdir ?? fromLink?.subdir
|
|
224
|
+
const pinned = resolveRef(listed, spec.ref ?? fromLink?.ref)
|
|
225
|
+
const dir = await makeTempDir(host)
|
|
226
|
+
const cleanup = () => removeTempDir(host, dir)
|
|
227
|
+
try {
|
|
228
|
+
const work = `${dir}/repo`
|
|
229
|
+
await must(git(host, ['init', '-q', '--template=', work], 60_000, ISOLATED_ENV), 'could not prepare a folder for the fetch')
|
|
230
|
+
await onStage?.(`fetching ${pinned.sha.slice(0, 12)}`)
|
|
231
|
+
const fetched = await boundedFetch(host, [
|
|
232
|
+
'-C', work, '-c', 'protocol.allow=never', '-c', 'protocol.https.allow=always', '-c', 'fetch.unpackLimit=1',
|
|
233
|
+
'fetch', '--depth', '1', '--no-tags', '--no-recurse-submodules', '-q', spec.url, pinned.sha,
|
|
234
|
+
])
|
|
235
|
+
if (fetched.exitCode !== 0) {
|
|
236
|
+
// The size limit stops git's index-pack as it writes the pack; git then
|
|
237
|
+
// says so and gives up. A fetch that stops without a word is likely it too.
|
|
238
|
+
if (/index-pack failed|unpack-objects failed|file size limit exceeded/i.test(fetched.stderr)) {
|
|
239
|
+
throw new Error(`the commit weighs more than ${MAX_FETCH_BYTES / 1024 / 1024} MB, too much to scan`)
|
|
240
|
+
}
|
|
241
|
+
if (fetched.stderr.trim() === '') throw new Error(`the fetch stopped without a message; the commit may weigh more than ${MAX_FETCH_BYTES / 1024 / 1024} MB`)
|
|
242
|
+
throw failure(fetched, `could not fetch ${pinned.sha}`)
|
|
243
|
+
}
|
|
244
|
+
const counted = await must(git(host, ['-C', work, 'count-objects', '-v'], 60_000, ISOLATED_ENV), 'could not measure the fetch')
|
|
245
|
+
const kib = ['size', 'size-pack'].reduce((sum, key) => sum + Number(new RegExp(`^${key}:\\s*(\\d+)`, 'm').exec(counted.stdout)?.[1] ?? 0), 0)
|
|
246
|
+
if (kib * 1024 > MAX_FETCH_BYTES) throw new Error(`the commit weighs more than ${MAX_FETCH_BYTES / 1024 / 1024} MB, too much to scan`)
|
|
247
|
+
// What the checkout will write, measured before it is written: a small
|
|
248
|
+
// pack can hold files that compress to almost nothing.
|
|
249
|
+
const tree = await must(git(host, ['-C', work, 'ls-tree', '-r', '-z', '-l', '--full-tree', pinned.sha], 60_000, ISOLATED_ENV), 'could not list the commit')
|
|
250
|
+
const entries = parseLsTree(tree.stdout)
|
|
251
|
+
const bytes = entries.reduce((sum, entry) => sum + (entry.size ?? 0), 0)
|
|
252
|
+
if (entries.length > LIMITS.maxEntries) throw new Error(`the commit holds more than ${LIMITS.maxEntries} files`)
|
|
253
|
+
if (bytes > LIMITS.maxTotalBytes) throw new Error(`the commit's files add up to more than ${LIMITS.maxTotalBytes / 1024 / 1024} MB, too much to scan`)
|
|
254
|
+
await must(
|
|
255
|
+
git(
|
|
256
|
+
host,
|
|
257
|
+
['-C', work, '-c', 'core.autocrlf=false', '-c', 'core.symlinks=true', '-c', 'filter.lfs.smudge=', '-c', 'filter.lfs.process=', '-c', 'filter.lfs.required=false', 'checkout', '-q', '--detach', pinned.sha],
|
|
258
|
+
120_000,
|
|
259
|
+
ISOLATED_ENV,
|
|
260
|
+
),
|
|
261
|
+
`could not check out ${pinned.sha}`,
|
|
262
|
+
)
|
|
263
|
+
// The assertion that closes Plugin4Shell: what HEAD is, not what was asked.
|
|
264
|
+
const head = (await must(git(host, ['-C', work, 'rev-parse', '--verify', 'HEAD'], 60_000, ISOLATED_ENV), 'could not read the checkout')).stdout.trim()
|
|
265
|
+
if (head !== pinned.sha) throw new Error(`the checkout landed on ${safeLine(head)}, not on the pinned commit ${pinned.sha}`)
|
|
266
|
+
const source: PinnedSource = { url: spec.url, ...pinned, defaultBranch: listed.defaultBranch?.replace(/^refs\/heads\//, ''), subdir: subdir }
|
|
267
|
+
let root = work
|
|
268
|
+
// Every step of the folder must be a plain folder: a link anywhere on the
|
|
269
|
+
// way could lead out of the checkout to your own files.
|
|
270
|
+
for (const part of subdir?.split('/') ?? []) {
|
|
271
|
+
root = `${root}/${part}`
|
|
272
|
+
const stat = await host.stat(root, { resolve: false }).catch(() => undefined)
|
|
273
|
+
if (stat?.isLink === true) throw new Error(`"${safeLine(subdir ?? '')}" passes through a symbolic link, which could lead outside the repository`)
|
|
274
|
+
if (stat === undefined || stat.kind !== 'dir') throw new Error(`the commit has no folder "${safeLine(subdir ?? '')}"`)
|
|
275
|
+
}
|
|
276
|
+
return { root, source, results: [sourceRow(source), refsRow(listed), checkoutRow(entries, subdir)], cleanup }
|
|
277
|
+
} catch (error) {
|
|
278
|
+
await cleanup().catch(() => undefined)
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function git(host: Host, args: readonly string[], timeoutMs = 60_000, env: Record<string, string> = GIT_ENV): Promise<RunResult> {
|
|
284
|
+
return host.run(['git', ...args], { timeoutMs, env })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type TreeEntry = { mode: string; type: string; oid: string; size?: number; path: string }
|
|
288
|
+
|
|
289
|
+
/** `git ls-tree -r -z -l` output: one entry per NUL, with each blob's size. */
|
|
290
|
+
function parseLsTree(stdout: string): TreeEntry[] {
|
|
291
|
+
const out: TreeEntry[] = []
|
|
292
|
+
for (const entry of stdout.split(String.fromCharCode(0))) {
|
|
293
|
+
const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64}) +(\d+|-)\t([\s\S]+)$/.exec(entry)
|
|
294
|
+
if (match === null) continue
|
|
295
|
+
const [, mode, type, oid, size, path] = match as unknown as [string, string, string, string, string, string]
|
|
296
|
+
out.push({ mode, type, oid, size: size === '-' ? undefined : Number(size), path })
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function must(result: Promise<RunResult>, what: string): Promise<RunResult> {
|
|
302
|
+
const done = await result
|
|
303
|
+
if (done.exitCode !== 0) throw failure(done, what)
|
|
304
|
+
return done
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function failure(result: RunResult, what: string): Error {
|
|
308
|
+
const line = result.stderr.split('\n').find(text => text.trim() !== '')
|
|
309
|
+
return new Error(`${what}: ${safeLine(line?.trim().slice(0, 300) ?? `git exited with ${result.exitCode}`)}`)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const TEMP_NAME = /[\\/]ai-install-guard\.[A-Za-z0-9._-]{6,}$/
|
|
313
|
+
|
|
314
|
+
/** A new, empty folder under the system's temp folder, owned by the guard. */
|
|
315
|
+
async function makeTempDir(host: Host): Promise<string> {
|
|
316
|
+
const posix = await host.run(['mktemp', '-d', '-t', 'ai-install-guard.XXXXXXXX']).catch(() => undefined)
|
|
317
|
+
const made = posix !== undefined && posix.exitCode === 0 ? posix.stdout.trim() : await windowsTempDir(host)
|
|
318
|
+
if (!TEMP_NAME.test(made)) throw new Error('could not make a temporary folder for the fetch')
|
|
319
|
+
return made.replace(/\\/g, '/')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const WINDOWS_MKTEMP =
|
|
323
|
+
"$p = Join-Path ([IO.Path]::GetTempPath()) ('ai-install-guard.' + [guid]::NewGuid().ToString('N').Substring(0, 12)); New-Item -ItemType Directory -Path $p | Out-Null; [Console]::Out.Write($p)"
|
|
324
|
+
|
|
325
|
+
async function windowsTempDir(host: Host): Promise<string> {
|
|
326
|
+
const result = await host.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_MKTEMP]).catch(() => undefined)
|
|
327
|
+
return result !== undefined && result.exitCode === 0 ? result.stdout.trim() : ''
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Removes a folder this module made, and nothing else. */
|
|
331
|
+
async function removeTempDir(host: Host, dir: string): Promise<void> {
|
|
332
|
+
if (!TEMP_NAME.test(dir)) return
|
|
333
|
+
const posix = await host.run(['rm', '-rf', '--', dir]).catch(() => undefined)
|
|
334
|
+
if (posix !== undefined && posix.exitCode === 0) return
|
|
335
|
+
await host.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Remove-Item -LiteralPath $env:GUARD_DIR -Recurse -Force'], { env: { GUARD_DIR: dir } }).catch(() => undefined)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function sourceRow(source: PinnedSource): CheckResult {
|
|
339
|
+
const what =
|
|
340
|
+
source.refKind === 'commit'
|
|
341
|
+
? 'pinned to the commit you named'
|
|
342
|
+
: source.refKind === 'tag'
|
|
343
|
+
? `tag ${safeLine(source.ref ?? '')} (a tag can be moved later; this scan holds the commit)`
|
|
344
|
+
: source.refKind === 'branch'
|
|
345
|
+
? `branch ${safeLine(source.ref ?? '')} (a branch moves; this scan holds the commit)`
|
|
346
|
+
: `the default branch${source.defaultBranch === undefined ? '' : ` ${safeLine(source.defaultBranch)}`} (it moves; this scan holds the commit)`
|
|
347
|
+
return row(
|
|
348
|
+
{
|
|
349
|
+
id: 'provenance.source',
|
|
350
|
+
group: 'provenance',
|
|
351
|
+
title: 'Source and pinned commit',
|
|
352
|
+
advice: 'Everything below describes this one commit. Installing anything else needs a new scan.',
|
|
353
|
+
},
|
|
354
|
+
[{ file: '-', excerpt: `${safeLine(source.url)}${source.subdir === undefined ? '' : ` folder ${safeLine(source.subdir)}`} at ${source.sha}: ${what}`, status: 'info' }],
|
|
355
|
+
'info',
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Branches and tags named like a commit id, or like git's own FETCH_HEAD, are
|
|
361
|
+
* how a pinned install is made to check out other code (Plugin4Shell). There
|
|
362
|
+
* is no honest reason for one; a short hex name is only odd.
|
|
363
|
+
*/
|
|
364
|
+
function refsRow(listed: RemoteRefs): CheckResult {
|
|
365
|
+
const evidence: Evidence[] = []
|
|
366
|
+
for (const name of listed.refs.keys()) {
|
|
367
|
+
const match = /^refs\/(heads|tags)\/(.+)$/.exec(name)
|
|
368
|
+
if (match === null) continue
|
|
369
|
+
const kind = match[1] === 'heads' ? 'branch' : 'tag'
|
|
370
|
+
const short = match[2] as string
|
|
371
|
+
const isDefault = name === listed.defaultBranch
|
|
372
|
+
const label = `the ${isDefault ? 'default ' : ''}${kind} "${safeLine(short)}"`
|
|
373
|
+
if (FULL_SHA.test(short.toLowerCase())) evidence.push({ file: '-', excerpt: `${label} is named like a commit id, which can make a pinned install check out other code`, status: 'fail' })
|
|
374
|
+
else if (SPECIAL_REFS.has(short)) evidence.push({ file: '-', excerpt: `${label} is named like git's own ${short}, which can make a pinned install check out other code`, status: 'fail' })
|
|
375
|
+
else if (SHORT_SHA.test(short) && /\d/.test(short) && /[a-f]/.test(short)) evidence.push({ file: '-', excerpt: `${label} is named like a short commit id`, status: 'warn' })
|
|
376
|
+
}
|
|
377
|
+
return row(
|
|
378
|
+
{
|
|
379
|
+
id: 'provenance.refs',
|
|
380
|
+
group: 'provenance',
|
|
381
|
+
title: 'Branch and tag names',
|
|
382
|
+
advice: 'A branch named like a commit id or like FETCH_HEAD lets the repository swap in other code when a client checks out a pinned commit by name. Treat the repository as hostile.',
|
|
383
|
+
},
|
|
384
|
+
capEvidence(evidence, 20),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** What a plain checkout leaves out: submodules, whose code lives elsewhere. */
|
|
389
|
+
function checkoutRow(entries: readonly TreeEntry[], subdir: string | undefined): CheckResult {
|
|
390
|
+
const evidence: Evidence[] = []
|
|
391
|
+
for (const { mode, oid, path } of entries) {
|
|
392
|
+
if (mode !== '160000') continue
|
|
393
|
+
if (subdir !== undefined && path !== subdir && !path.startsWith(`${subdir}/`)) continue
|
|
394
|
+
const shown = subdir === undefined ? path : path.slice(subdir.length + 1) || '.'
|
|
395
|
+
evidence.push({ file: shown, excerpt: `a submodule at commit ${oid.slice(0, 12)}: its code is fetched separately and is not part of this scan`, status: 'warn' })
|
|
396
|
+
}
|
|
397
|
+
return row(
|
|
398
|
+
{
|
|
399
|
+
id: 'provenance.checkout',
|
|
400
|
+
group: 'provenance',
|
|
401
|
+
title: 'Code outside the commit',
|
|
402
|
+
advice: 'A submodule pulls code from another repository at install time. That code was not scanned.',
|
|
403
|
+
},
|
|
404
|
+
capEvidence(evidence, 20),
|
|
405
|
+
)
|
|
406
|
+
}
|
package/hooks/scan/report.ts
CHANGED
|
@@ -25,8 +25,12 @@ export function formatReport(report: ScanReport, options: { details?: boolean }
|
|
|
25
25
|
if (inventory !== undefined) {
|
|
26
26
|
const files = inventory.entries.filter(entry => entry.kind === 'file').length
|
|
27
27
|
lines.push(`${report.kind === undefined ? '' : `A ${report.kind} · `}${files} files (${formatBytes(inventory.totalBytes)}) · tree ${inventory.treeHash}`)
|
|
28
|
-
|
|
29
|
-
if (
|
|
28
|
+
const { source } = report
|
|
29
|
+
if (source === undefined) lines.push(`Folder: ${safeLine(inventory.root)}`)
|
|
30
|
+
else lines.push(`Source: ${safeLine(source.url)}${source.subdir === undefined ? '' : ` folder ${safeLine(source.subdir)}`} at commit ${source.sha}`)
|
|
31
|
+
// A fetched commit's .git folder is the guard's own, not the candidate's.
|
|
32
|
+
const skipped = source === undefined ? inventory.skipped : inventory.skipped.filter(path => path !== '.git')
|
|
33
|
+
if (skipped.length > 0) lines.push(`Not read: ${skipped.map(safeLine).join(', ')}`)
|
|
30
34
|
}
|
|
31
35
|
if (report.results.length === 0) return lines.join('\n')
|
|
32
36
|
|
|
@@ -47,13 +51,18 @@ export function countsLine(report: ScanReport): string {
|
|
|
47
51
|
function formatRow(result: CheckResult, details: boolean): string[] {
|
|
48
52
|
const shown = details ? result.evidence : result.evidence.slice(0, 1)
|
|
49
53
|
const head = ` ${LABEL[result.status]} ${result.title}`
|
|
54
|
+
const summary = (result.summary ?? []).map(line => safeLine(redactSecrets(line)))
|
|
50
55
|
if (!details) {
|
|
56
|
+
if (summary[0] !== undefined) return [`${head}: ${summary[0]}`]
|
|
51
57
|
const first = shown[0]
|
|
52
58
|
const more = result.evidence.length > 1 ? ` (+${result.evidence.length - 1} more)` : ''
|
|
53
59
|
return [first === undefined ? head : `${head}: ${evidenceLine(first)}${more}`]
|
|
54
60
|
}
|
|
55
61
|
const out = [head]
|
|
56
|
-
|
|
62
|
+
// What it means first, then where, then what to do.
|
|
63
|
+
for (const line of summary) out.push(` ${line}`)
|
|
64
|
+
if (summary.length > 0 && shown.length > 0) out.push(' Where:')
|
|
65
|
+
for (const item of shown) out.push(` ${summary.length > 0 ? ' ' : ''}${item.status === undefined || item.status === result.status ? '' : `${LABEL[item.status]} `}${evidenceLine(item)}`)
|
|
57
66
|
if (result.status === 'fail' || result.status === 'warn') out.push(` Advice: ${result.advice}`)
|
|
58
67
|
return out
|
|
59
68
|
}
|
package/hooks/scan/scan.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { codeChecks } from './checks/code'
|
|
2
2
|
import { buildContext, isRecord, parseJson, type CandidateKind, type ScanContext } from './checks/context'
|
|
3
3
|
import { hookChecks } from './checks/hooks'
|
|
4
|
+
import { mcpChecks } from './checks/mcp'
|
|
4
5
|
import { modChecks } from './checks/mod'
|
|
5
6
|
import { promptChecks } from './checks/prompts'
|
|
7
|
+
import { badgeClaim, badgeRow, marketplacePinsCheck, type BadgeClaim } from './checks/provenance'
|
|
6
8
|
import { licenseCheck, qualityChecks } from './checks/quality'
|
|
7
9
|
import { secretChecks } from './checks/secrets'
|
|
8
10
|
import { structureChecks } from './checks/structure'
|
|
9
11
|
import { joinPath } from './checks/text'
|
|
10
|
-
import { GROUPS, rankOf, type CheckResult, type Evidence } from './checks/types'
|
|
12
|
+
import { GROUPS, rankOf, row, type CheckResult, type Evidence } from './checks/types'
|
|
11
13
|
import { runValidator } from './checks/validator'
|
|
12
14
|
import type { Host } from './host'
|
|
13
15
|
import { treeHash } from './hash'
|
|
14
16
|
import { ScanError, takeInventory, type Inventory, type InventoryEntry, type ScannedTree } from './inventory'
|
|
17
|
+
import { githubProvenance } from './github'
|
|
18
|
+
import { newLookupCache, outsideLookups, type LookupCache } from './lookups'
|
|
19
|
+
import { fetchPinned, type PinnedSource, type RemoteSpec } from './remote'
|
|
15
20
|
import { decide, type Verdict } from './verdict'
|
|
16
21
|
|
|
17
22
|
export type ScanReport = {
|
|
@@ -21,11 +26,19 @@ export type ScanReport = {
|
|
|
21
26
|
inventory?: Inventory
|
|
22
27
|
results: CheckResult[]
|
|
23
28
|
verdict: Verdict
|
|
29
|
+
/** For a remote scan: the one commit that was fetched and checked. */
|
|
30
|
+
source?: PinnedSource
|
|
31
|
+
/** What an AI Install Guard badge in the README claims, judged in `results`. */
|
|
32
|
+
badgeClaim?: BadgeClaim
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export type ScanOptions = {
|
|
27
36
|
/** Run `claude plugin validate` on the candidate (on by default). */
|
|
28
37
|
validate?: boolean
|
|
38
|
+
/** The outside lookups (OSV, the official plugin list): off unless turned on. */
|
|
39
|
+
lookups?: boolean
|
|
40
|
+
/** Shared by one scan's lookups, so a marketplace and its plugins ask once. */
|
|
41
|
+
lookupCache?: LookupCache
|
|
29
42
|
/** Told each stage as it starts, for progress in the pane. */
|
|
30
43
|
onStage?: (stage: string) => Promise<void> | void
|
|
31
44
|
/**
|
|
@@ -41,6 +54,7 @@ export async function scanFolder(host: Host, target: string, path: string, rawOp
|
|
|
41
54
|
// Progress is a courtesy: a failure to show it never fails the scan.
|
|
42
55
|
const options: ScanOptions = {
|
|
43
56
|
...rawOptions,
|
|
57
|
+
...(rawOptions.lookups === true && rawOptions.lookupCache === undefined ? { lookupCache: newLookupCache() } : {}),
|
|
44
58
|
onStage: async stage => {
|
|
45
59
|
try {
|
|
46
60
|
await rawOptions.onStage?.(stage)
|
|
@@ -78,7 +92,10 @@ export async function scanFolder(host: Host, target: string, path: string, rawOp
|
|
|
78
92
|
results = mergeResults(results, [missingSourceRow(source.name, source.path)])
|
|
79
93
|
continue
|
|
80
94
|
}
|
|
81
|
-
|
|
95
|
+
// A listed plugin's own README badge is judged against its own rows.
|
|
96
|
+
const nestedCtx = buildContext(nested)
|
|
97
|
+
const nestedResults = withBadgeClaim(await checksFor(host, nestedCtx, options), badgeClaim(nestedCtx.readme), { treeHash: nested.inventory.treeHash })
|
|
98
|
+
results = mergeResults(results, prefixed(nestedResults, source.path))
|
|
82
99
|
}
|
|
83
100
|
} catch (error) {
|
|
84
101
|
return { target, kind: ctx.kind, inventory: tree.inventory, results: [], verdict: decide([], `a check failed: ${reasonOf(error)}`) }
|
|
@@ -99,17 +116,41 @@ export async function scanFolder(host: Host, target: string, path: string, rawOp
|
|
|
99
116
|
])
|
|
100
117
|
results = [...results, ...extra]
|
|
101
118
|
}
|
|
102
|
-
|
|
103
|
-
|
|
119
|
+
const claim = badgeClaim(ctx.readme)
|
|
120
|
+
results = withBadgeClaim(sortResults(results), claim, { treeHash: tree.inventory.treeHash })
|
|
121
|
+
return { target, kind: ctx.kind, inventory: tree.inventory, results, verdict: decide(results), ...(claim === undefined ? {} : { badgeClaim: claim }) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The results with a README badge judged against everything else: the badge
|
|
126
|
+
* row is computed last, from the verdict the other rows give, so a badge can
|
|
127
|
+
* add a warning and never lift a verdict.
|
|
128
|
+
*/
|
|
129
|
+
export function withBadgeClaim(results: readonly CheckResult[], claim: BadgeClaim | undefined, version: { sha?: string; treeHash?: string }): CheckResult[] {
|
|
130
|
+
if (claim === undefined) return [...results]
|
|
131
|
+
// Evidence about nested plugins' badges, merged in already, stays; this
|
|
132
|
+
// README's own is judged again.
|
|
133
|
+
const rest = results.flatMap(result => {
|
|
134
|
+
if (result.id !== 'provenance.badge') return [result]
|
|
135
|
+
const kept = result.evidence.filter(item => item.file !== claim.file)
|
|
136
|
+
return kept.length === 0 ? [] : [row({ id: result.id, group: result.group, title: result.title, advice: result.advice, summary: result.summary }, kept, 'info')]
|
|
137
|
+
})
|
|
138
|
+
return sortResults(mergeResults(rest, [badgeRow(claim, decide(rest).kind, version)]))
|
|
104
139
|
}
|
|
105
140
|
|
|
106
141
|
async function checksFor(host: Host, ctx: ScanContext, options: ScanOptions): Promise<CheckResult[]> {
|
|
107
|
-
|
|
142
|
+
// Claude Code validates plugins and marketplaces; a standalone skill or a
|
|
143
|
+
// plain folder has no manifest for it to read.
|
|
144
|
+
const hasManifest = ctx.files.some(file => file.path === '.claude-plugin/plugin.json' || file.path === '.claude-plugin/marketplace.json')
|
|
145
|
+
const validator = options.validate === false || ctx.kind === 'folder' || (ctx.kind === 'skill' && !hasManifest) ? undefined : await runValidator(host, ctx.tree.inventory.root)
|
|
108
146
|
return [
|
|
109
147
|
licenseCheck(ctx),
|
|
148
|
+
...marketplacePinsCheck(ctx),
|
|
110
149
|
...structureChecks(ctx, validator),
|
|
111
150
|
...modChecks(ctx, validator),
|
|
112
151
|
...hookChecks(ctx),
|
|
152
|
+
...mcpChecks(ctx),
|
|
153
|
+
...(options.lookups === true ? await outsideLookups(host, ctx, options.lookupCache) : []),
|
|
113
154
|
...promptChecks(ctx),
|
|
114
155
|
...codeChecks(ctx),
|
|
115
156
|
...secretChecks(ctx),
|
|
@@ -125,6 +166,44 @@ type MarketplaceSource =
|
|
|
125
166
|
| { kind: 'overflow'; count: number }
|
|
126
167
|
|
|
127
168
|
/** The plugins a `.claude-plugin/marketplace.json` lists, other than the root itself. */
|
|
169
|
+
/**
|
|
170
|
+
* Scans a remote source: fetches the one commit its ref names into the
|
|
171
|
+
* guard's own temporary folder, scans that folder, and removes it. A failure
|
|
172
|
+
* to fetch or verify is "Could not verify", never a pass.
|
|
173
|
+
*/
|
|
174
|
+
export async function scanRemote(host: Host, target: string, spec: RemoteSpec, options: ScanOptions = {}): Promise<ScanReport> {
|
|
175
|
+
let fetched
|
|
176
|
+
try {
|
|
177
|
+
fetched = await fetchPinned(host, spec, async stage => {
|
|
178
|
+
try {
|
|
179
|
+
await options.onStage?.(stage)
|
|
180
|
+
} catch {
|
|
181
|
+
// Progress is a courtesy, as in scanFolder.
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { target, results: [], verdict: decide([], reasonOf(error)) }
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const report = await scanFolder(host, target, fetched.root, options)
|
|
189
|
+
if (report.verdict.kind === 'unverified') return { ...report, source: fetched.source }
|
|
190
|
+
// What GitHub says about where it came from, then the badge, judged again
|
|
191
|
+
// with the source's own rows counted.
|
|
192
|
+
// Progress is a courtesy here too: a failure to show it, thrown or
|
|
193
|
+
// rejected, never fails the scan.
|
|
194
|
+
try {
|
|
195
|
+
await options.onStage?.('reading the repository history')
|
|
196
|
+
} catch {
|
|
197
|
+
// ignored on purpose
|
|
198
|
+
}
|
|
199
|
+
const history = await githubProvenance(host, fetched.source)
|
|
200
|
+
const results = withBadgeClaim(sortResults([...fetched.results, ...history, ...report.results]), report.badgeClaim, { sha: fetched.source.sha, treeHash: report.inventory?.treeHash })
|
|
201
|
+
return { ...report, source: fetched.source, results, verdict: decide(results) }
|
|
202
|
+
} finally {
|
|
203
|
+
await fetched.cleanup().catch(() => undefined)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
128
207
|
function marketplaceSources(ctx: ScanContext): MarketplaceSource[] {
|
|
129
208
|
const raw = ctx.text('.claude-plugin/marketplace.json')
|
|
130
209
|
const parsed = parseJson(raw)
|
|
@@ -177,10 +256,11 @@ async function subTree(tree: ScannedTree, prefix: string): Promise<ScannedTree |
|
|
|
177
256
|
}
|
|
178
257
|
}
|
|
179
258
|
|
|
180
|
-
/** A nested plugin's rows, with every place named from the marketplace root. */
|
|
259
|
+
/** A nested plugin's rows, with every place and its summary named from the marketplace root. */
|
|
181
260
|
function prefixed(results: readonly CheckResult[], prefix: string): CheckResult[] {
|
|
182
261
|
return results.map(result => ({
|
|
183
262
|
...result,
|
|
263
|
+
...(result.summary === undefined ? {} : { summary: result.summary.map((line, index) => (index === 0 ? `${prefix}: ${line}` : line)) }),
|
|
184
264
|
evidence: result.evidence.map(item =>
|
|
185
265
|
item.file === '-' || item.file === '' ? { ...item, excerpt: `${prefix}: ${item.excerpt}` } : { ...item, file: `${prefix}/${item.file}` },
|
|
186
266
|
),
|
|
@@ -210,8 +290,14 @@ export function mergeResults(base: readonly CheckResult[], extra: readonly Check
|
|
|
210
290
|
const kept = existing.evidence[at] as Evidence
|
|
211
291
|
if (rankOf(item.status ?? result.status) > rankOf(kept.status ?? existing.status)) existing.evidence[at] = item
|
|
212
292
|
}
|
|
213
|
-
// A pass on one side and a note on the other stay a pass; anything worse
|
|
214
|
-
|
|
293
|
+
// A pass on one side and a note on the other stay a pass; anything worse
|
|
294
|
+
// wins, and brings its own summary, so the reason shown is the worst one.
|
|
295
|
+
if (rankOf(result.status) > rankOf(existing.status)) {
|
|
296
|
+
existing.status = result.status
|
|
297
|
+
if (result.summary !== undefined) existing.summary = result.summary
|
|
298
|
+
} else if (existing.summary === undefined && result.summary !== undefined) {
|
|
299
|
+
existing.summary = result.summary
|
|
300
|
+
}
|
|
215
301
|
if (order[result.severity] > order[existing.severity]) existing.severity = result.severity
|
|
216
302
|
existing.evidence.sort((a, b) => rankOf(b.status ?? existing.status) - rankOf(a.status ?? existing.status))
|
|
217
303
|
}
|
package/hooks/scan/verdict.ts
CHANGED
|
@@ -4,7 +4,8 @@ export type VerdictKind = 'safe' | 'caution' | 'reject' | 'unverified'
|
|
|
4
4
|
|
|
5
5
|
export const RECOMMENDATION: Record<VerdictKind, string> = {
|
|
6
6
|
safe: 'Safe to install',
|
|
7
|
-
|
|
7
|
+
// Not a lesser "safe": powerful access worth knowing about before you install.
|
|
8
|
+
caution: 'Review what it can do',
|
|
8
9
|
reject: 'Do not install',
|
|
9
10
|
unverified: 'Could not verify',
|
|
10
11
|
}
|
|
@@ -20,7 +21,7 @@ export type Verdict = {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* Any FAIL: do not install. Else any WARN:
|
|
24
|
+
* Any FAIL: do not install. Else any WARN: review what it can do. Else safe.
|
|
24
25
|
* A scan that could not finish is "could not verify" whatever it found.
|
|
25
26
|
* Rows only ever add to this: nothing a row says can lower another row.
|
|
26
27
|
*/
|
|
@@ -45,8 +46,13 @@ function severityRank(result: CheckResult): number {
|
|
|
45
46
|
return order[result.severity] * 10 + rankOf(result.status)
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* `Title: what it means` from the row's plain summary, or `Title: file:line
|
|
51
|
+
* what` from its worst piece of evidence when it has none.
|
|
52
|
+
*/
|
|
49
53
|
export function reasonOf(result: CheckResult): string {
|
|
54
|
+
const headline = result.summary?.[0]
|
|
55
|
+
if (headline !== undefined) return `${result.title}: ${headline}`
|
|
50
56
|
const worst = [...result.evidence].sort((a, b) => rankOf(b.status ?? result.status) - rankOf(a.status ?? result.status))[0]
|
|
51
57
|
if (worst === undefined) return result.title
|
|
52
58
|
return `${result.title}: ${locate(worst.file, worst.line)}${worst.excerpt}`
|