ai-install-guard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +38 -0
- package/README.md +71 -10
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +12 -3
- package/hooks/guard/pane.tsx +54 -7
- package/hooks/guard/view.ts +19 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +77 -18
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +111 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +406 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
package/hooks/register.tsx
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { EngineInterface, Register } from 'claude-code'
|
|
2
2
|
import { atom, update } from 'claude-code'
|
|
3
3
|
|
|
4
|
+
import { badgeFor } from './guard/badge'
|
|
4
5
|
import { decisionFor, forgetDecisions, loadDecisions, loadLastView, MAX_REPORT_CHARS, saveLastView } from './guard/decisions'
|
|
5
6
|
import { drawPane, REPORT_TEXT } from './guard/pane'
|
|
6
7
|
import { PANE_ID, scanningView, viewOf } from './guard/view'
|
|
7
8
|
import { modHost } from './mod-host'
|
|
8
9
|
import type { Host } from './scan/host'
|
|
10
|
+
import { looksRemote, parseRemote, type RemoteSpec } from './scan/remote'
|
|
9
11
|
import { formatReport } from './scan/report'
|
|
10
12
|
import { safeLine } from './scan/safe-text'
|
|
11
|
-
import { scanFolder } from './scan/scan'
|
|
13
|
+
import { scanFolder, scanRemote } from './scan/scan'
|
|
12
14
|
|
|
13
15
|
// Claude Code reads every state reference from the file that uses it, so each
|
|
14
16
|
// module declares its own atom for the pane's view.
|
|
@@ -17,26 +19,36 @@ const viewAtom = atom({ plugin: 'ai-install-guard', key: 'view' } as const, null
|
|
|
17
19
|
const USAGE = [
|
|
18
20
|
'Usage:',
|
|
19
21
|
' /guard scan <path> Scan a local plugin, mod or skill folder without installing it',
|
|
20
|
-
' /guard scan
|
|
22
|
+
' /guard scan <repository> Fetch one commit of a repository and scan it: https://host/owner/repo,',
|
|
23
|
+
' github:owner/repo or owner/repo, with an optional @ref',
|
|
24
|
+
' --ref <branch|tag|commit> The commit to scan (the default branch otherwise)',
|
|
25
|
+
' --path <folder> A folder inside the repository',
|
|
26
|
+
' --details The full report as text',
|
|
21
27
|
' /guard report Reopen the last report',
|
|
28
|
+
' /guard badge A README badge for the last scan (Safe to install or Review what it can do)',
|
|
22
29
|
' /guard list Your approved and rejected versions',
|
|
23
30
|
' /guard forget <name> Forget decisions about a name, so it is judged again',
|
|
24
31
|
].join('\n')
|
|
25
32
|
|
|
26
|
-
export const register: Register = on => {
|
|
33
|
+
export const register: Register = (on, options) => {
|
|
34
|
+
// Off unless you turn it on: the lookups send package and plugin names out.
|
|
35
|
+
const lookups = options.outsideLookups === true
|
|
36
|
+
|
|
27
37
|
on('session.start', async ($, e, next) => {
|
|
28
38
|
await $.command.register({
|
|
29
39
|
name: 'guard',
|
|
30
40
|
description: 'Scan a plugin, mod, skill or MCP server without installing it',
|
|
31
|
-
argumentHint: 'scan <path> | report | list | forget <name>',
|
|
41
|
+
argumentHint: 'scan <path | repository> | report | badge | list | forget <name>',
|
|
32
42
|
})
|
|
33
43
|
return next(e)
|
|
34
44
|
})
|
|
35
45
|
|
|
36
46
|
on('command.run', { command: 'guard' }, async ($, e) => {
|
|
37
|
-
const { sub, rest, details } = splitArgs(e.args)
|
|
38
|
-
if (
|
|
47
|
+
const { sub, rest, details, ref, path, error } = splitArgs(e.args)
|
|
48
|
+
if (error !== undefined) return { text: `AI Install Guard: ${error}\n\n${USAGE}` }
|
|
49
|
+
if (sub === 'scan' && rest !== '') return { text: await scan($, rest, details, { ref, path }, lookups) }
|
|
39
50
|
if (sub === 'report') return { text: await reopen($) }
|
|
51
|
+
if (sub === 'badge') return { text: await badge($) }
|
|
40
52
|
if (sub === 'list') return { text: await list($) }
|
|
41
53
|
if (sub === 'forget' && rest !== '') {
|
|
42
54
|
const count = (await forgetDecisions($, rest)).length
|
|
@@ -54,7 +66,7 @@ export const register: Register = on => {
|
|
|
54
66
|
* findings stay in the pane. Where no pane can show (a headless run), or with
|
|
55
67
|
* --details, the answer is the full text report.
|
|
56
68
|
*/
|
|
57
|
-
async function scan($: EngineInterface, target: string, details: boolean): Promise<string> {
|
|
69
|
+
async function scan($: EngineInterface, target: string, details: boolean, flags: { ref?: string; path?: string }, lookups: boolean): Promise<string> {
|
|
58
70
|
const host = modHost($)
|
|
59
71
|
const opened = await $.ui.open({ id: PANE_ID, title: 'AI Install Guard', focus: true }).catch(() => ({ isPlaced: false }))
|
|
60
72
|
// Until this scan's report is stored, Copy report has nothing to copy, never
|
|
@@ -63,13 +75,16 @@ async function scan($: EngineInterface, target: string, details: boolean): Promi
|
|
|
63
75
|
await update($, viewAtom, () => scanningView(target, 'approve'))
|
|
64
76
|
$.ui.status(`AI Install Guard: scanning ${safeLine(target)}`)
|
|
65
77
|
try {
|
|
66
|
-
const
|
|
78
|
+
const options = {
|
|
79
|
+
lookups,
|
|
67
80
|
// Stage names can carry a candidate's own words (a plugin's name).
|
|
68
|
-
onStage: stage => update($, viewAtom, current => (current === null ? current : { ...current, stage: safeLine(stage) })).then(() => undefined),
|
|
69
|
-
}
|
|
81
|
+
onStage: (stage: string) => update($, viewAtom, current => (current === null ? current : { ...current, stage: safeLine(stage) })).then(() => undefined),
|
|
82
|
+
}
|
|
83
|
+
const spec = await remoteSpecFor(host, target, flags)
|
|
84
|
+
const report = spec === undefined ? await scanFolder(host, target, await expandHome(host, target), options) : await scanRemote(host, target, spec, options)
|
|
70
85
|
// A tree you already decided on shows that decision, not the buttons
|
|
71
86
|
// again, unless it now judges worse than when you approved it.
|
|
72
|
-
const earlier = report.inventory === undefined ? undefined : await decisionFor($, report.inventory.treeHash, report.verdict.kind).catch(() => undefined)
|
|
87
|
+
const earlier = report.inventory === undefined ? undefined : await decisionFor($, { treeHash: report.inventory.treeHash, sha: report.source?.sha }, report.verdict.kind).catch(() => undefined)
|
|
73
88
|
const view = earlier === undefined ? viewOf(report, 'approve') : { ...viewOf(report, 'approve'), decision: earlier.decision, decidedAt: earlier.decidedAt }
|
|
74
89
|
const text = formatReport(report, { details: true })
|
|
75
90
|
// The report text is stored before the pane shows the verdict, so Copy
|
|
@@ -107,6 +122,19 @@ async function reopen($: EngineInterface): Promise<string> {
|
|
|
107
122
|
return typeof text === 'string' ? text : 'AI Install Guard: the last report is no longer stored.'
|
|
108
123
|
}
|
|
109
124
|
|
|
125
|
+
async function badge($: EngineInterface): Promise<string> {
|
|
126
|
+
const view = await loadLastView($)
|
|
127
|
+
const made = badgeFor(view)
|
|
128
|
+
if ('refusal' in made) return `AI Install Guard: ${made.refusal}`
|
|
129
|
+
return [
|
|
130
|
+
`AI Install Guard: a README badge for ${view?.target ?? 'the last scan'} at ${made.pin}. Paste it near the top of your README:`,
|
|
131
|
+
'',
|
|
132
|
+
made.markdown,
|
|
133
|
+
'',
|
|
134
|
+
'It names the exact version that was scanned. A change to your code needs a new scan and a new badge; the guard checks a badge against its own scan and never trusts it.',
|
|
135
|
+
].join('\n')
|
|
136
|
+
}
|
|
137
|
+
|
|
110
138
|
async function list($: EngineInterface): Promise<string> {
|
|
111
139
|
const decisions = await loadDecisions($)
|
|
112
140
|
if (decisions.length === 0) return 'AI Install Guard: no decisions yet.'
|
|
@@ -119,18 +147,49 @@ async function list($: EngineInterface): Promise<string> {
|
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
/**
|
|
122
|
-
* `scan --details "my folder"` → `{ sub: 'scan', details: true, rest: 'my folder' }`.
|
|
123
|
-
*
|
|
124
|
-
* quoted path that contains
|
|
150
|
+
* `scan --details --ref v1 "my folder"` → `{ sub: 'scan', details: true, ref: 'v1', rest: 'my folder' }`.
|
|
151
|
+
* Options count only as their own tokens right after the subcommand, so a
|
|
152
|
+
* quoted path that contains one is left whole.
|
|
125
153
|
*/
|
|
126
|
-
export function splitArgs(args: string): { sub: string; rest: string; details: boolean } {
|
|
154
|
+
export function splitArgs(args: string): { sub: string; rest: string; details: boolean; ref?: string; path?: string; error?: string } {
|
|
127
155
|
const trimmed = args.trim()
|
|
128
156
|
const space = trimmed.search(/\s/)
|
|
129
157
|
const sub = space === -1 ? trimmed : trimmed.slice(0, space)
|
|
130
158
|
let rest = space === -1 ? '' : trimmed.slice(space).trim()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
let details = false
|
|
160
|
+
let ref: string | undefined
|
|
161
|
+
let path: string | undefined
|
|
162
|
+
for (;;) {
|
|
163
|
+
const option = /^(--details|--ref|--path)(?:\s+|$)/.exec(rest)
|
|
164
|
+
if (option === null) break
|
|
165
|
+
rest = rest.slice(option[0].length)
|
|
166
|
+
if (option[1] === '--details') {
|
|
167
|
+
details = true
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
const value = /^("[^"]*"|'[^']*'|\S+)\s*/.exec(rest)
|
|
171
|
+
if (value === null || (value[1] as string).startsWith('--')) return { sub: sub.toLowerCase(), rest: '', details, error: `${option[1]} needs a value` }
|
|
172
|
+
rest = rest.slice(value[0].length)
|
|
173
|
+
if (option[1] === '--ref') ref = unquote(value[1] as string)
|
|
174
|
+
else path = unquote(value[1] as string)
|
|
175
|
+
}
|
|
176
|
+
return { sub: sub.toLowerCase(), rest: unquote(rest), details, ref, path }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The remote source `target` names, or undefined for a local folder. A bare
|
|
181
|
+
* owner/repo is a folder when one exists by that name and no --ref or --path
|
|
182
|
+
* says otherwise.
|
|
183
|
+
*/
|
|
184
|
+
async function remoteSpecFor(host: Host, target: string, flags: { ref?: string; path?: string }): Promise<RemoteSpec | undefined> {
|
|
185
|
+
const shape = looksRemote(target)
|
|
186
|
+
const asked = flags.ref !== undefined || flags.path !== undefined
|
|
187
|
+
if (shape === 'local') {
|
|
188
|
+
if (asked) throw new Error('--ref and --path apply to a repository, not to a folder')
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
if (shape === 'maybe' && !asked && (await host.stat(target).catch(() => undefined)) !== undefined) return undefined
|
|
192
|
+
return parseRemote(target, { ref: flags.ref, subdir: flags.path })
|
|
134
193
|
}
|
|
135
194
|
|
|
136
195
|
function unquote(text: string): string {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
WRITE_SHELL,
|
|
22
22
|
} from './patterns'
|
|
23
23
|
import { basename, extension, findAll, findFirst, spanKinds, type Match, type SpanKind } from './text'
|
|
24
|
+
import { describePrograms, programsIn, type ProgramUse } from './programs'
|
|
24
25
|
import { row, worse, type CheckResult, type Evidence, type Status } from './types'
|
|
25
26
|
|
|
26
27
|
const GROUP = 'code' as const
|
|
@@ -159,6 +160,7 @@ const ANSI = /\u001b\[[0-9;?]{0,40}[A-Za-z]|\u001b\][^\u0007\u001b]{0,1000}\u000
|
|
|
159
160
|
|
|
160
161
|
function hiddenCheck(ctx: ScanContext): CheckResult {
|
|
161
162
|
const evidence: Evidence[] = []
|
|
163
|
+
let foundLone = false
|
|
162
164
|
for (const file of ctx.files) {
|
|
163
165
|
const text = file.isText ? ctx.text(file.path) : undefined
|
|
164
166
|
if (text === undefined) continue
|
|
@@ -172,10 +174,24 @@ function hiddenCheck(ctx: ScanContext): CheckResult {
|
|
|
172
174
|
for (const match of findAll(text, VARIATION_SUPPLEMENT, 3)) {
|
|
173
175
|
evidence.push({ file: file.path, line: match.line, excerpt: `${[...match.text].length} invisible variation selectors in a row (a way to smuggle data)`, status: cap('fail') })
|
|
174
176
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
// In code a control that is all of a string, with nothing after it, is
|
|
178
|
+
// only a warning, and only in a language the tokenizer knows. Failures
|
|
179
|
+
// are found first, so no number of such strings can crowd out a real one.
|
|
180
|
+
const language = isCode ? (ctx.interpreters.get(file.path) ?? languageOf(file.path, text)) : 'other'
|
|
181
|
+
let kinds: ((index: number) => SpanKind) | undefined
|
|
182
|
+
const kindAt = (index: number): SpanKind => (kinds ??= spanKinds(text, language))(index)
|
|
183
|
+
const lone = (index: number): boolean => language !== 'other' && isLoneInString(text, index, kindAt)
|
|
184
|
+
for (const match of findAll(text, BIDI, 5, index => !lone(index))) {
|
|
177
185
|
evidence.push({ file: file.path, line: match.line, excerpt: `bidirectional control ${codePoints(match.text)}: text may read differently than it runs`, status: cap(isCode ? 'fail' : 'warn') })
|
|
178
186
|
}
|
|
187
|
+
if (language !== 'other') {
|
|
188
|
+
for (const match of findAll(text, BIDI, 3, lone)) {
|
|
189
|
+
const status = cap('warn')
|
|
190
|
+
if (status === 'warn') foundLone = true
|
|
191
|
+
const excerpt = `bidirectional control ${codePoints(match.text)} is all of a string: it cannot reorder code there, but the string looks empty; write it as ${escapeFor(match, file.path, text, language)}`
|
|
192
|
+
evidence.push({ file: file.path, line: match.line, excerpt, status })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
179
195
|
const zero = findAll(text, ZERO_WIDTH, 50)
|
|
180
196
|
if (zero.length > 0 && (isCode || isMarkdown(file.path))) {
|
|
181
197
|
const first = zero[0] as (typeof zero)[number]
|
|
@@ -191,12 +207,52 @@ function hiddenCheck(ctx: ScanContext): CheckResult {
|
|
|
191
207
|
id: 'code.hidden-characters',
|
|
192
208
|
group: GROUP,
|
|
193
209
|
title: 'Hidden characters',
|
|
194
|
-
advice:
|
|
210
|
+
advice: `Invisible tag characters carry text the model reads and you do not see. Bidirectional controls make code read differently than it runs. Neither belongs in a plugin.${
|
|
211
|
+
foundLone ? " A plugin that needs a direction mark, as one for right-to-left text does, should write it as an escape such as '\\u200f', never the raw character, which makes its string look empty." : ''
|
|
212
|
+
}`,
|
|
195
213
|
},
|
|
196
214
|
evidence,
|
|
197
215
|
)
|
|
198
216
|
}
|
|
199
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Whether the bidirectional control at `index` is all of a one-line string,
|
|
220
|
+
* as in `const RLM = '\u200f'`, with nothing after it on its line but blanks,
|
|
221
|
+
* `;`, `,` or a comment. What it can reorder runs to the end of its line, and
|
|
222
|
+
* there that is no code; what comes before it does not move.
|
|
223
|
+
*/
|
|
224
|
+
function isLoneInString(text: string, index: number, kindAt: (index: number) => SpanKind): boolean {
|
|
225
|
+
const quote = text[index - 1]
|
|
226
|
+
if ((quote !== "'" && quote !== '"') || text[index + 1] !== quote) return false
|
|
227
|
+
if (kindAt(index - 1) !== 'string' || kindAt(index) !== 'string' || kindAt(index + 1) !== 'string') return false
|
|
228
|
+
// The quote must open the string: what comes before it is no string, and no
|
|
229
|
+
// `}`, which may close a template's `${...}` and leave the quote as its text.
|
|
230
|
+
if (index >= 2 && (kindAt(index - 2) === 'string' || text[index - 2] === '}')) return false
|
|
231
|
+
// Only LF ends the line here. U+2028 and U+2029 end a JavaScript line, but
|
|
232
|
+
// editors often show them inline and U+2028 ends no bidi paragraph, so code
|
|
233
|
+
// after one can still be reordered: that fails.
|
|
234
|
+
for (let i = index + 2; i < text.length && text[i] !== '\n'; i += 1) {
|
|
235
|
+
const char = text[i]
|
|
236
|
+
if (char === ' ' || char === '\t' || char === ';' || char === ',') continue
|
|
237
|
+
// Only a CR that ends the line: a lone one ends a line too, and code may follow it.
|
|
238
|
+
if (char === '\r' && (i + 1 === text.length || text[i + 1] === '\n')) continue
|
|
239
|
+
const kind = kindAt(i)
|
|
240
|
+
if (kind !== 'comment' && kind !== 'trailing-comment') return false
|
|
241
|
+
}
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `'\u200f'` for a lone control, as its language writes the escape. */
|
|
246
|
+
function escapeFor(match: Match, path: string, text: string, language: Language): string {
|
|
247
|
+
const hex = (match.text.codePointAt(0) as number).toString(16).padStart(4, '0')
|
|
248
|
+
if (language !== 'shell') return `${text[match.index - 1]}\\u${hex}${text[match.index - 1]}`
|
|
249
|
+
// fish reads the escape bare. Other shells differ (sh has no such escape,
|
|
250
|
+
// and bash before 4.2 not in $'...'), but every printf takes octal bytes.
|
|
251
|
+
if ((interpreterOf(text) || extension(path)) === 'fish') return `\\u${hex}`
|
|
252
|
+
const octal = [...new TextEncoder().encode(match.text)].map(byte => `\\${byte.toString(8)}`).join('')
|
|
253
|
+
return `"$(printf '${octal}')"`
|
|
254
|
+
}
|
|
255
|
+
|
|
200
256
|
/** Tag characters U+E0020..U+E007E mirror printable ASCII. */
|
|
201
257
|
export function decodeTags(text: string): string {
|
|
202
258
|
let out = ''
|
|
@@ -213,6 +269,9 @@ function codePoints(text: string): string {
|
|
|
213
269
|
|
|
214
270
|
function execCheck(ctx: ScanContext): CheckResult {
|
|
215
271
|
const evidence: Evidence[] = []
|
|
272
|
+
const uses: ProgramUse[] = []
|
|
273
|
+
let buildsCode = 0
|
|
274
|
+
let downloads = 0
|
|
216
275
|
for (const file of codeFiles(ctx)) {
|
|
217
276
|
// Code files, and anything live code runs or loads whatever its name.
|
|
218
277
|
if (!isCodeFile(file.path, file.text) && !LIVE_ROLES.has(file.role)) continue
|
|
@@ -227,18 +286,35 @@ function execCheck(ctx: ScanContext): CheckResult {
|
|
|
227
286
|
evidence.push(...evidenceFor(file, pattern, inModule ? 'fail' : 'warn', match => `${what}: ${match.excerpt}`, 3))
|
|
228
287
|
}
|
|
229
288
|
evidence.push(...evidenceFor(file, DOWNLOAD_AND_RUN, 'fail', match => `downloads or decodes code and runs it: ${match.excerpt}`, 3))
|
|
289
|
+
// The summary counts live code only: tests and docs are only reported.
|
|
290
|
+
if (isLive(file.role)) {
|
|
291
|
+
const keep = (index: number) => !['comment', 'regex', 'trailing-comment'].includes(file.kindAt(index))
|
|
292
|
+
uses.push(...programsIn(file.path, file.text, file.language, keep))
|
|
293
|
+
// The same language rules as the evidence, each place counted once.
|
|
294
|
+
const fromText = file.language === 'js' ? [CODE_FROM_TEXT] : file.language === 'python' ? [PYTHON_CODE_FROM_TEXT] : file.language === 'shell' ? [SHELL_EVAL] : [CODE_FROM_TEXT, PYTHON_CODE_FROM_TEXT, SHELL_EVAL]
|
|
295
|
+
buildsCode += new Set(fromText.flatMap(pattern => findAll(file.text, pattern, 50, keep).map(match => match.line))).size
|
|
296
|
+
downloads += findAll(file.text, DOWNLOAD_AND_RUN, 50, keep).length
|
|
297
|
+
}
|
|
230
298
|
}
|
|
231
299
|
return row(
|
|
232
300
|
{
|
|
233
301
|
id: 'code.execution',
|
|
234
302
|
group: GROUP,
|
|
235
303
|
title: 'Running programs and building code at run time',
|
|
236
|
-
advice:
|
|
304
|
+
advice:
|
|
305
|
+
'Check that each program fits what it says it does. Shells, compilers and programs chosen while it runs can do anything, so look at those first. Inside a mod any of this is a red flag: a mod reaches your machine only through Claude Code.',
|
|
306
|
+
summary: summaryOrFallback(describePrograms(uses, { buildsCode, downloads }), evidence),
|
|
237
307
|
},
|
|
238
308
|
evidence,
|
|
239
309
|
)
|
|
240
310
|
}
|
|
241
311
|
|
|
312
|
+
/** A row that found something but could not name it still says so in words. */
|
|
313
|
+
function summaryOrFallback(summary: string[], evidence: readonly Evidence[]): string[] {
|
|
314
|
+
if (summary.length > 0 || !evidence.some(item => item.status === 'warn' || item.status === 'fail')) return summary
|
|
315
|
+
return ['Starts other programs; which ones could not be read from the code']
|
|
316
|
+
}
|
|
317
|
+
|
|
242
318
|
function obfuscationCheck(ctx: ScanContext): CheckResult {
|
|
243
319
|
const evidence: Evidence[] = []
|
|
244
320
|
for (const file of codeFiles(ctx)) {
|
|
@@ -402,12 +478,36 @@ function denseLine(text: string): Match | undefined {
|
|
|
402
478
|
|
|
403
479
|
function credentialsCheck(ctx: ScanContext): CheckResult {
|
|
404
480
|
const evidence: Evidence[] = []
|
|
481
|
+
const secrets = new Set<string>()
|
|
482
|
+
const places = new Set<string>()
|
|
483
|
+
const files = new Set<string>()
|
|
484
|
+
const sending = new Set<string>()
|
|
405
485
|
for (const file of codeFiles(ctx)) {
|
|
406
|
-
|
|
486
|
+
// Hook scripts and MCP configuration are judged where their target is
|
|
487
|
+
// known: the hooks check, and mcp.credentials with the server's host.
|
|
488
|
+
if (file.role === 'hook-script' || file.role === 'mcp-config') continue
|
|
407
489
|
const sends = [NETWORK_CODE, NETWORK_COMMAND, COLLECTION_HOST].some(pattern => active(file, pattern, 1).length > 0)
|
|
408
490
|
const lead = sends ? 'with network code in the same file: ' : ''
|
|
409
|
-
|
|
410
|
-
|
|
491
|
+
const paths = evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3)
|
|
492
|
+
const names = evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3)
|
|
493
|
+
evidence.push(...paths, ...names)
|
|
494
|
+
if (!isLive(file.role)) continue
|
|
495
|
+
for (const match of active(file, CREDENTIAL_PATH, 20)) places.add(match.text.trim().replace(/\/$/, ''))
|
|
496
|
+
// A dump of the whole environment (JSON.stringify(process.env), printenv) is every secret at once.
|
|
497
|
+
for (const match of active(file, SECRET_ENV, 20)) secrets.add(/process\.env|os\.environ|printenv|\benv\b/.test(match.text) ? 'all your environment variables' : secretName(match))
|
|
498
|
+
if (paths.length + names.length > 0) {
|
|
499
|
+
files.add(file.path)
|
|
500
|
+
if (sends) sending.add(file.path)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const summary: string[] = []
|
|
504
|
+
const things = [...secrets, ...places]
|
|
505
|
+
if (things.length > 0) {
|
|
506
|
+
summary.push(`Reads ${things.slice(0, 5).join(', ')}${things.length > 5 ? ` and ${things.length - 5} more` : ''}${sending.size > 0 ? ', in code that can also reach the network' : ''}`)
|
|
507
|
+
if (secrets.size > 0) summary.push(`Secrets from your environment: ${[...secrets].join(', ')}`)
|
|
508
|
+
if (places.size > 0) summary.push(`Credential files and stores: ${[...places].join(', ')}`)
|
|
509
|
+
if (sending.size > 0) summary.push(`Worth a look: ${[...sending].slice(0, 4).join(', ')} can also send data over the network`)
|
|
510
|
+
summary.push(`In ${files.size} file${files.size === 1 ? '' : 's'}: ${[...files].slice(0, 4).join(', ')}${files.size > 4 ? ` and ${files.size - 4} more` : ''}`)
|
|
411
511
|
}
|
|
412
512
|
return row(
|
|
413
513
|
{
|
|
@@ -415,6 +515,7 @@ function credentialsCheck(ctx: ScanContext): CheckResult {
|
|
|
415
515
|
group: GROUP,
|
|
416
516
|
title: 'Credentials and secrets read by code',
|
|
417
517
|
advice: 'Code that reads your keys, tokens or browser data and can reach the network can steal them. Do not install it unless that is plainly its job.',
|
|
518
|
+
summary,
|
|
418
519
|
},
|
|
419
520
|
evidence,
|
|
420
521
|
)
|
|
@@ -506,7 +607,8 @@ function domainsCheck(ctx: ScanContext): CheckResult {
|
|
|
506
607
|
const authority = /\/\/([^/?#]{0,300})/.exec(match.text)?.[1] ?? ''
|
|
507
608
|
const hasUserInfo = authority.includes('@')
|
|
508
609
|
const host = (/(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+)/.exec(authority.slice(authority.lastIndexOf('@') + 1))?.[1] ?? '').toLowerCase()
|
|
509
|
-
|
|
610
|
+
// A one-word host (https://x) is a placeholder, not a place on the internet.
|
|
611
|
+
if (host === '' || (!host.includes('.') && !host.startsWith('['))) continue
|
|
510
612
|
if (hasUserInfo) evidence.push(judged(file, match, 'warn', `an address whose real host hides after "@": ${host}`))
|
|
511
613
|
if (/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|example\.(?:com|org|net))$/.test(host)) continue
|
|
512
614
|
if (!hosts.has(host)) hosts.set(host, { file: file.path, line: match.line })
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { McpServer, ScanContext } from './context'
|
|
2
|
+
import { COLLECTION_HOST, CREDENTIAL_PATH, DOWNLOAD_AND_RUN, NETWORK_COMMAND, SECRET_ENV, UNPINNED_RUNNER } from './patterns'
|
|
3
|
+
import { capEvidence, row, type CheckResult, type Evidence, type Status } from './types'
|
|
4
|
+
|
|
5
|
+
const GROUP = 'mcp' as const
|
|
6
|
+
|
|
7
|
+
/** Words in a variable's name that say nothing about whose credential it is. */
|
|
8
|
+
const GENERIC_WORDS = new Set(['api', 'key', 'keys', 'token', 'tokens', 'auth', 'oauth', 'secret', 'access', 'session', 'client', 'bot', 'app', 'personal', 'private', 'password', 'credentials', 'application', 'hub', 'code'])
|
|
9
|
+
|
|
10
|
+
/** Short or product names in a variable, and the service they belong to. */
|
|
11
|
+
const SERVICE_WORDS: Record<string, string> = { gh: 'github', hf: 'huggingface', hugging: 'huggingface', face: 'huggingface', claude: 'anthropic', node: 'npm' }
|
|
12
|
+
|
|
13
|
+
/** The domains each service's keys belong on. A host must be one of them or below one. */
|
|
14
|
+
const SERVICE_DOMAINS: Record<string, readonly string[]> = {
|
|
15
|
+
github: ['github.com', 'githubcopilot.com', 'githubusercontent.com', 'ghe.com'],
|
|
16
|
+
gitlab: ['gitlab.com'],
|
|
17
|
+
anthropic: ['anthropic.com', 'claude.ai', 'claude.com'],
|
|
18
|
+
openai: ['openai.com'],
|
|
19
|
+
aws: ['amazonaws.com', 'aws.amazon.com', 'aws.dev'],
|
|
20
|
+
google: ['googleapis.com', 'google.com'],
|
|
21
|
+
azure: ['azure.com', 'microsoft.com', 'windows.net', 'azure.net'],
|
|
22
|
+
slack: ['slack.com'],
|
|
23
|
+
stripe: ['stripe.com'],
|
|
24
|
+
huggingface: ['huggingface.co'],
|
|
25
|
+
npm: ['npmjs.org', 'npmjs.com'],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A `${NAME}` or `$NAME` reference to an environment variable. */
|
|
29
|
+
const ENV_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]{0,100})(?::-[^}]{0,200})?\}|\$([A-Za-z_][A-Za-z0-9_]{0,100})/g
|
|
30
|
+
|
|
31
|
+
/** An address anywhere in a command line, a shell script's included. */
|
|
32
|
+
const ADDRESS = /\b(?:https?|wss?):\/\/[^\s'"`<>)]{1,500}/gi
|
|
33
|
+
|
|
34
|
+
/** Container options that take the next argument as their value. */
|
|
35
|
+
const VALUE_FLAGS = new Set([
|
|
36
|
+
'-a', '--attach', '-e', '--env', '--env-file', '-v', '--volume', '--mount', '-l', '--label', '--label-file', '--name', '-p', '--publish', '--network', '--net',
|
|
37
|
+
'-w', '--workdir', '--entrypoint', '-u', '--user', '--platform', '--pull', '-h', '--hostname', '--cpus', '-m', '--memory', '--add-host', '--device', '--cap-add',
|
|
38
|
+
'--cap-drop', '--restart', '--log-driver', '--log-opt', '--security-opt', '--tmpfs', '--ulimit', '--gpus', '--shm-size', '--runtime', '--ipc', '--pid', '--uts',
|
|
39
|
+
'--userns', '--cgroupns', '--dns', '--expose', '--group-add', '--cidfile', '--stop-signal', '--stop-timeout', '--health-cmd', '--memory-swap', '--cpuset-cpus',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
/** Group 5: what each MCP server runs or talks to, and what it is handed. */
|
|
43
|
+
export function mcpChecks(ctx: ScanContext): CheckResult[] {
|
|
44
|
+
if (ctx.mcpServers.length === 0) return []
|
|
45
|
+
return [inventoryCheck(ctx.mcpServers), launchCheck(ctx.mcpServers), remoteCheck(ctx.mcpServers), credentialsCheck(ctx.mcpServers)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function place(server: McpServer, excerpt: string, status: Status): Evidence {
|
|
49
|
+
return { file: server.file, line: server.line, excerpt: `${server.name}: ${excerpt}`, status }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function commandLine(server: McpServer): string {
|
|
53
|
+
return [server.command ?? '', ...server.args].join(' ').trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every remote address a server talks to or is given: its own URL, and any
|
|
58
|
+
* address in its arguments (inside a shell script too), environment and
|
|
59
|
+
* headers. Local ones are left out. All are judged; the rows cap what they list.
|
|
60
|
+
*/
|
|
61
|
+
function remoteUrls(server: McpServer): { url: URL; given: boolean }[] {
|
|
62
|
+
const found: { url: URL; given: boolean }[] = []
|
|
63
|
+
const texts = [
|
|
64
|
+
...(server.url === undefined ? [] : [{ text: server.url, given: false }]),
|
|
65
|
+
...[...server.args, ...Object.values(server.env), ...Object.values(server.headers)].map(text => ({ text, given: true })),
|
|
66
|
+
]
|
|
67
|
+
for (const { text, given } of texts) {
|
|
68
|
+
for (const match of text.matchAll(ADDRESS)) {
|
|
69
|
+
// Bounded work for a config written to exhaust it.
|
|
70
|
+
if (found.length >= 500) return found
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(match[0])
|
|
73
|
+
if (!isLocal(url.hostname)) found.push({ url, given })
|
|
74
|
+
} catch {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isLocal(host: string): boolean {
|
|
83
|
+
return /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1|0\.0\.0\.0)$/i.test(host)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function inventoryCheck(servers: readonly McpServer[]): CheckResult {
|
|
87
|
+
const evidence = servers.map(server => {
|
|
88
|
+
const what = server.url !== undefined ? `${server.type ?? 'http'} server at ${server.url}` : `runs ${commandLine(server) || '(no command)'}`
|
|
89
|
+
const env = Object.keys(server.env)
|
|
90
|
+
return place(server, `${what}${env.length > 0 ? `; environment: ${env.slice(0, 10).join(', ')}` : ''}`, 'info')
|
|
91
|
+
})
|
|
92
|
+
return row(
|
|
93
|
+
{
|
|
94
|
+
id: 'mcp.servers',
|
|
95
|
+
group: GROUP,
|
|
96
|
+
title: 'MCP servers it adds',
|
|
97
|
+
advice: 'Each MCP server runs as a program on your machine or talks to a server elsewhere, and Claude can call every tool it offers.',
|
|
98
|
+
},
|
|
99
|
+
capEvidence(evidence, 30, 'info'),
|
|
100
|
+
'info',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function launchCheck(servers: readonly McpServer[]): CheckResult {
|
|
105
|
+
const evidence: Evidence[] = []
|
|
106
|
+
for (const server of servers) {
|
|
107
|
+
if (server.command === undefined) continue
|
|
108
|
+
const line = commandLine(server)
|
|
109
|
+
const program = baseName(server.command)
|
|
110
|
+
if (DOWNLOAD_AND_RUN.test(line)) {
|
|
111
|
+
evidence.push(place(server, `downloads code and runs it: ${line}`, 'fail'))
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (UNPINNED_RUNNER.test(line)) {
|
|
115
|
+
evidence.push(place(server, `fetches a package that is not pinned to an exact version each time it starts: ${line}`, 'warn'))
|
|
116
|
+
} else if (/^(?:npx|bunx|pnpx|uvx|pipx|npm|pnpm|yarn|bun|deno)$/.test(program)) {
|
|
117
|
+
evidence.push(place(server, `fetches or runs a package when it starts; the package is not part of this scan: ${line}`, 'info'))
|
|
118
|
+
}
|
|
119
|
+
if (runsInlineScript(program, server.args)) evidence.push(place(server, `runs an inline shell script: ${line}`, 'warn'))
|
|
120
|
+
if (/^(?:docker|podman|nerdctl)$/.test(program) && server.args.includes('run')) {
|
|
121
|
+
const image = containerImage(server.args)
|
|
122
|
+
if (image === undefined) evidence.push(place(server, `runs a container whose image could not be told apart: ${line}`, 'warn'))
|
|
123
|
+
else if (!/@sha256:[0-9a-f]{64}$/.test(image)) evidence.push(place(server, `runs the container image ${image}, not pinned by digest`, 'warn'))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return row(
|
|
127
|
+
{
|
|
128
|
+
id: 'mcp.launch',
|
|
129
|
+
group: GROUP,
|
|
130
|
+
title: 'How each server starts',
|
|
131
|
+
advice: 'A server that downloads code when it starts runs whatever is published then, not what was scanned. Prefer an exact version or a digest.',
|
|
132
|
+
},
|
|
133
|
+
capEvidence(evidence, 30),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A shell given a script as text: -c (alone or with other letters, -ec), /c, -Command. */
|
|
138
|
+
function runsInlineScript(program: string, args: readonly string[]): boolean {
|
|
139
|
+
if (/^(?:ba|z|da|k|fi)?sh$/.test(program)) return args.some(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg))
|
|
140
|
+
if (/^cmd(?:\.exe)?$/.test(program)) return args.some(arg => /^\/[ck]$/i.test(arg))
|
|
141
|
+
if (/^(?:powershell|pwsh)(?:\.exe)?$/.test(program)) return args.some(arg => /^-(?:c|command|e|ec|enc|encodedcommand)$/i.test(arg))
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The image a `docker run` starts: the first argument after `run` that is not an option or its value. */
|
|
146
|
+
function containerImage(args: readonly string[]): string | undefined {
|
|
147
|
+
let index = args.indexOf('run') + 1
|
|
148
|
+
while (index < args.length) {
|
|
149
|
+
const arg = args[index] as string
|
|
150
|
+
if (arg === '--') return args[index + 1]
|
|
151
|
+
if (!arg.startsWith('-')) return arg
|
|
152
|
+
index += !arg.includes('=') && VALUE_FLAGS.has(arg) ? 2 : 1
|
|
153
|
+
}
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function remoteCheck(servers: readonly McpServer[]): CheckResult {
|
|
158
|
+
const evidence: Evidence[] = []
|
|
159
|
+
for (const server of servers) {
|
|
160
|
+
for (const { url, given } of remoteUrls(server)) {
|
|
161
|
+
const host = url.hostname.toLowerCase()
|
|
162
|
+
const via = given ? ' (an address in its configuration)' : ''
|
|
163
|
+
if (url.protocol === 'http:' || url.protocol === 'ws:') {
|
|
164
|
+
evidence.push(place(server, `talks to ${host}${via} without encryption`, 'fail'))
|
|
165
|
+
} else if (COLLECTION_HOST.test(`${host}${url.pathname}`)) {
|
|
166
|
+
evidence.push(place(server, `talks to ${host}${via}, a host often used to collect data`, 'fail'))
|
|
167
|
+
} else if (/^\d{1,3}(?:\.\d{1,3}){3}$|^\[/.test(host)) {
|
|
168
|
+
evidence.push(place(server, `talks to a raw IP address${via}: ${host}`, 'warn'))
|
|
169
|
+
} else {
|
|
170
|
+
evidence.push(place(server, `talks to a remote server${via}: ${host}`, 'warn'))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return row(
|
|
175
|
+
{
|
|
176
|
+
id: 'mcp.remote',
|
|
177
|
+
group: GROUP,
|
|
178
|
+
title: 'Remote servers',
|
|
179
|
+
advice: 'A remote server sees what Claude sends its tools and can change its tools at any time. Check that each host belongs to the publisher.',
|
|
180
|
+
},
|
|
181
|
+
capEvidence(evidence, 30),
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function credentialsCheck(servers: readonly McpServer[]): CheckResult {
|
|
186
|
+
const evidence: Evidence[] = []
|
|
187
|
+
for (const server of servers) {
|
|
188
|
+
const urls = remoteUrls(server)
|
|
189
|
+
const line = commandLine(server)
|
|
190
|
+
const runner = server.command !== undefined && UNPINNED_RUNNER.test(line)
|
|
191
|
+
// A command that reads a credential file, and can send it somewhere.
|
|
192
|
+
if (server.command !== undefined && CREDENTIAL_PATH.test(line)) {
|
|
193
|
+
const sends = urls.length > 0 || NETWORK_COMMAND.test(line)
|
|
194
|
+
evidence.push(place(server, `${sends ? 'reads a credential file and talks to the network' : 'reads a credential file'}: ${line}`, sends ? 'fail' : 'warn'))
|
|
195
|
+
}
|
|
196
|
+
const handed = new Map<string, string>()
|
|
197
|
+
for (const [key, value] of [...Object.entries(server.env), ...Object.entries(server.headers)]) {
|
|
198
|
+
for (const name of referencedVariables(value)) handed.set(name, key)
|
|
199
|
+
if (SECRET_ENV.test(key)) handed.set(key, key)
|
|
200
|
+
}
|
|
201
|
+
for (const arg of server.args) for (const name of referencedVariables(arg)) if (!handed.has(name)) handed.set(name, 'its arguments')
|
|
202
|
+
for (const name of referencedVariables(server.url ?? '')) handed.set(name, 'url')
|
|
203
|
+
for (const [name, via] of handed) {
|
|
204
|
+
if (!SECRET_ENV.test(name)) continue
|
|
205
|
+
if (urls.length > 0) {
|
|
206
|
+
for (const { url } of urls) {
|
|
207
|
+
const host = url.hostname.toLowerCase()
|
|
208
|
+
evidence.push(
|
|
209
|
+
belongsTo(name, host)
|
|
210
|
+
? place(server, `sends ${name} (in ${via}) to ${host}`, 'warn')
|
|
211
|
+
: place(server, `sends ${name} (in ${via}) to ${host}, which is not the service it belongs to`, 'fail'),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
} else if (runner) {
|
|
215
|
+
evidence.push(place(server, `hands ${name} (in ${via}) to a package fetched when it starts, which this scan cannot read`, 'warn'))
|
|
216
|
+
} else {
|
|
217
|
+
evidence.push(place(server, `is given ${name} (in ${via})`, 'info'))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return row(
|
|
222
|
+
{
|
|
223
|
+
id: 'mcp.credentials',
|
|
224
|
+
group: GROUP,
|
|
225
|
+
title: 'Secrets given to servers',
|
|
226
|
+
advice: 'A server given a key can use it as you. A key sent to a host that is not its own service is a common way to steal it.',
|
|
227
|
+
},
|
|
228
|
+
capEvidence(evidence, 30),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function referencedVariables(text: string): string[] {
|
|
233
|
+
const names: string[] = []
|
|
234
|
+
for (const match of text.matchAll(ENV_REFERENCE)) {
|
|
235
|
+
const name = match[1] ?? match[2]
|
|
236
|
+
if (name !== undefined) names.push(name)
|
|
237
|
+
}
|
|
238
|
+
return names
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether a key named `name` belongs on `host`: the host is one of its
|
|
243
|
+
* service's own domains or below one. For a service with no list, the
|
|
244
|
+
* service's word must be the host's own name (notion for mcp.notion.so), not
|
|
245
|
+
* any label: github.evil.example is evil.example's.
|
|
246
|
+
*/
|
|
247
|
+
function belongsTo(name: string, host: string): boolean {
|
|
248
|
+
const labels = host.split('.')
|
|
249
|
+
for (const word of vendorWords(name)) {
|
|
250
|
+
const domains = SERVICE_DOMAINS[word]
|
|
251
|
+
if (domains !== undefined) {
|
|
252
|
+
if (domains.some(domain => host === domain || host.endsWith(`.${domain}`))) return true
|
|
253
|
+
} else if (labels.length >= 2 && labels[labels.length - 2] === word) {
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The words of a variable's name that name a service: GITHUB_TOKEN gives github. */
|
|
261
|
+
function vendorWords(name: string): string[] {
|
|
262
|
+
return name
|
|
263
|
+
.toLowerCase()
|
|
264
|
+
.split('_')
|
|
265
|
+
.map(word => SERVICE_WORDS[word] ?? word)
|
|
266
|
+
.filter(word => word.length >= 3 && !GENERIC_WORDS.has(word))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function baseName(command: string): string {
|
|
270
|
+
return (command.split(/[\\/]/).pop() ?? command).toLowerCase()
|
|
271
|
+
}
|