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.
@@ -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 --details <path> The same, with the full report as text',
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 (sub === 'scan' && rest !== '') return { text: await scan($, rest, details) }
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 report = await scanFolder(host, target, await expandHome(host, target), {
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
- * `--details` counts only as its own token right after the subcommand, so a
124
- * quoted path that contains it is left whole.
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
- const details = /^--details(?:\s|$)/.test(rest)
132
- if (details) rest = rest.slice('--details'.length).trim()
133
- return { sub: sub.toLowerCase(), rest: unquote(rest), details }
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
- const bidi = findAll(text, BIDI, 5)
176
- for (const match of bidi) {
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: '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.',
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: 'Inside a mod this is a red flag: mods reach the machine only through $. In a script, check that each command it runs is one its purpose needs.',
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
- if (file.role === 'hook-script') continue
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
- evidence.push(...evidenceFor(file, CREDENTIAL_PATH, sends ? 'fail' : 'warn', match => `${lead}${match.excerpt}`, 3))
410
- evidence.push(...evidenceFor(file, SECRET_ENV, sends ? 'fail' : 'warn', match => `${lead}reads ${secretName(match)}`, 3))
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
- if (host === '') continue
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
+ }