ai-install-guard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -1,4 +1,6 @@
1
- import type { ModuleInfo, ModuleUse, ScanContext } from './context'
1
+ import { isLive, type ModuleInfo, type ModuleUse, type ScanContext } from './context'
2
+ import { describePrograms, type ProgramUse } from './programs'
3
+ import { spanKinds } from './text'
2
4
  import { row, type CheckResult, type Evidence, type Status } from './types'
3
5
  import type { ValidatorReport } from './validator'
4
6
 
@@ -105,28 +107,23 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
105
107
  ...dedupe(info.events).map(use => ({ use, kind: 'event' as const, capability: eventCapability(use) })),
106
108
  ...dedupe(info.calls).map(use => ({ use, kind: 'call' as const, capability: callCapability(use.name) })),
107
109
  ])
108
- const explained = explanationsOf(ctx)
109
- // The README must spell the call or event out as a name of its own; a wildcard is never explained.
110
- const explainedCache = new Map<string, boolean>()
111
- const isExplained = (name: string): boolean => {
112
- const cached = explainedCache.get(name)
113
- if (cached !== undefined) return cached
114
- const answer = explains(name)
115
- explainedCache.set(name, answer)
110
+ // A call is explained when the README (or the manifest's description)
111
+ // names it as a name of its own, not after a negation ("never calls
112
+ // process.run"), on a line or table row that says something about it: at
113
+ // least three words besides the name. A wildcard is never explained. The
114
+ // words are kept to quote beside the call, marked as the candidate's.
115
+ const sources: { where: Explanation['where']; text: string }[] = [
116
+ { where: 'README', text: (ctx.readme?.text ?? '').replace(/<!--[\s\S]{0,20000}?-->/g, ' ') },
117
+ { where: 'description', text: typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : '' },
118
+ ]
119
+ const explanationCache = new Map<string, Explanation | undefined>()
120
+ const explanationFor = (name: string): Explanation | undefined => {
121
+ if (explanationCache.has(name)) return explanationCache.get(name)
122
+ const answer = findExplanation(name, sources)
123
+ explanationCache.set(name, answer)
116
124
  return answer
117
125
  }
118
- const explains = (name: string): boolean => {
119
- if (name.length < 5 || !/^[a-z]+\.[a-zA-Z.]+$/.test(name)) return false
120
- const escaped = name.replace(/\./g, '\\.')
121
- const mention = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'gi')
122
- // A mention right after a negation ("we never call process.run",
123
- // "no process.run") says it does not use the call, so it explains nothing.
124
- for (const found of explained.matchAll(mention)) {
125
- const before = explained.slice(Math.max(0, (found.index ?? 0) - 40), found.index).split(/[.!?|\n]/).pop() ?? ''
126
- if (!/\b(?:never|not|no|without|don['’]t|doesn['’]t|won['’]t|cannot|can['’]t)\b(?:\W+\w+){0,3}\W*$/i.test(before)) return true
127
- }
128
- return false
129
- }
126
+ const isExplained = (name: string): boolean => explanationFor(name) !== undefined
130
127
 
131
128
  const inventory: Evidence[] = uses.map(({ use, kind, capability }) => ({
132
129
  file: use.file,
@@ -148,7 +145,8 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
148
145
  status: explainedHere ? 'warn' : 'fail',
149
146
  })
150
147
  } else if (capability.risk === 'medium') {
151
- medium.push({ file: use.file, line: use.line > 0 ? use.line : undefined, excerpt, status: 'warn' })
148
+ // Still a warning when explained: network access always deserves a look.
149
+ medium.push({ file: use.file, line: use.line > 0 ? use.line : undefined, excerpt: isExplained(use.name) ? `${excerpt} (the README names it)` : excerpt, status: 'warn' })
152
150
  }
153
151
  }
154
152
  const allCalls = new Set(modules.flatMap(info => info.calls.map(call => call.name)))
@@ -194,6 +192,7 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
194
192
  group: GROUP,
195
193
  title: 'High-risk capabilities',
196
194
  advice: 'Running programs, writing files, deciding tool permissions, judging other plugins or changing the system prompt is what a mod needs to take over a session. Install only if its purpose needs it and its README says why.',
195
+ summary: capabilitySummary(uses.filter(item => item.capability.risk === 'high'), explanationFor, ctx, modules, allCalls.has('fs.read') && allCalls.has('http.fetch')),
197
196
  },
198
197
  high,
199
198
  'fail',
@@ -204,6 +203,7 @@ export function modChecks(ctx: ScanContext, validator: ValidatorReport | undefin
204
203
  group: GROUP,
205
204
  title: 'Network, environment and prompt access',
206
205
  advice: 'Check every address it talks to and every variable it reads against what it says it does.',
206
+ summary: capabilitySummary(uses.filter(item => item.capability.risk === 'medium'), explanationFor, ctx, modules, false),
207
207
  },
208
208
  medium,
209
209
  ),
@@ -250,17 +250,21 @@ function eventCapability(use: ModuleUse): Capability {
250
250
  if (SENSITIVE_COMMANDS.has(command)) return { risk: 'high', plain: `intercepts /${command}` }
251
251
  return { risk: 'low', plain: `answers /${command}` }
252
252
  }
253
+ // A tool hook narrowed to one tool is described as that tool's, still high risk.
254
+ if (use.name === 'tool.call' || use.name === 'tool.check') {
255
+ const tool = /\btool\s*:\s*['"`]([\w.-]{1,64})['"`]/.exec(use.detail ?? '')?.[1]
256
+ if (tool !== undefined) {
257
+ return use.name === 'tool.call'
258
+ ? { risk: 'high', plain: `sees every ${tool} call and can deny, rewrite or answer it` }
259
+ : { risk: 'high', plain: `decides whether ${tool} calls are allowed, so it can auto-approve them` }
260
+ }
261
+ }
253
262
  if (Object.hasOwn(EVENTS, use.name)) return EVENTS[use.name] as Capability
254
263
  const namespace = use.name.split('.')[0] ?? ''
255
264
  return Object.hasOwn(NAMESPACES, namespace) ? (NAMESPACES[namespace] as Capability) : { risk: 'low', plain: 'an event this scanner does not know' }
256
265
  }
257
266
 
258
267
  /** Lowercased names the README or description spells out, such as `process.run`. */
259
- function explanationsOf(ctx: ScanContext): string {
260
- // Only what a reader sees: HTML comments are hidden when the README renders.
261
- const visible = (ctx.readme?.text ?? '').replace(/<!--[\s\S]{0,20000}?-->/g, ' ')
262
- return [visible, typeof ctx.manifest?.description === 'string' ? ctx.manifest.description : ''].join('\n\n').toLowerCase()
263
- }
264
268
 
265
269
  /** Adds what Claude Code's own validator saw and the source scan missed. */
266
270
  function withValidator(info: ModuleInfo, validator: ValidatorReport | undefined): ModuleInfo {
@@ -288,3 +292,92 @@ function dedupe(uses: readonly ModuleUse[]): ModuleUse[] {
288
292
  }
289
293
 
290
294
  export type { Status }
295
+
296
+ /** Where a call is explained, and the words that explain it. */
297
+ type Explanation = { where: 'README' | 'description'; quote: string }
298
+
299
+ const NEGATION = /\b(?:never|not|no|without|don['’]t|doesn['’]t|won['’]t|cannot|can['’]t)\b(?:\W+\w+){0,3}\W*$/i
300
+
301
+ function findExplanation(name: string, sources: readonly { where: Explanation['where']; text: string }[]): Explanation | undefined {
302
+ if (name.length < 5 || !/^[a-z]+\.[a-zA-Z.]+$/.test(name)) return undefined
303
+ const escaped = name.replace(/\./g, '\\.')
304
+ const mentions = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'gi')
305
+ const mention = new RegExp(`(?<![\\w.$])${escaped}(?![\\w])`, 'i')
306
+ for (const { where, text } of sources) {
307
+ for (const line of text.split('\n')) {
308
+ for (const found of line.matchAll(mentions)) {
309
+ const index = found.index ?? 0
310
+ const before = line.slice(Math.max(0, index - 40), index).split(/[.!?|]/).pop() ?? ''
311
+ if (NEGATION.test(before)) continue
312
+ // A table row: every cell but the one that is only the call's name;
313
+ // a reason cell that repeats the name is still the reason.
314
+ const cells = line.trim().startsWith('|') ? line.split('|').map(cell => cell.trim()).filter(cell => cell !== '' && !/^:?-+:?$/.test(cell)) : [line]
315
+ const plain = (text: string) => text.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
316
+ const words = cells.filter(cell => plain(cell).toLowerCase() !== name.toLowerCase()).map(plain).join(' ')
317
+ const said = plain((cells.length > 1 ? words : line).replace(mentions, ''))
318
+ if ((said.match(/[A-Za-z]{3,}/g) ?? []).length < 3) continue
319
+ const quote = cells.length > 1 ? words : line.replace(/[`*_]/g, '').replace(/\s+/g, ' ').trim()
320
+ return { where, quote: quote.length > 220 ? `${quote.slice(0, 219)}…` : quote }
321
+ }
322
+ }
323
+ }
324
+ return undefined
325
+ }
326
+
327
+ /**
328
+ * A capability row in plain words: what the mod can do and whether its
329
+ * README says why, the README's own words for it (marked as the
330
+ * candidate's), then the programs it starts when it runs any.
331
+ */
332
+ function capabilitySummary(
333
+ uses: readonly { use: ModuleUse; kind: 'event' | 'call'; capability: Capability }[],
334
+ explanationFor: (name: string) => Explanation | undefined,
335
+ ctx: ScanContext,
336
+ modules: readonly ModuleInfo[],
337
+ readsAndSends: boolean,
338
+ ): string[] | undefined {
339
+ const names = [...new Set(uses.map(item => item.use.name))]
340
+ if (names.length === 0) return undefined
341
+ const plain = [...new Set(uses.map(item => item.capability.plain))]
342
+ const told = names.every(name => explanationFor(name) !== undefined)
343
+ const lines = [`${plain.join('; ').replace(/^./, char => char.toUpperCase())}${told ? '; its README says why' : '; its README does not say why'}`]
344
+ if (readsAndSends) lines.push('Reads files and sends requests, so it could send your files out')
345
+ // The reasons come before the program list, so a long list cannot push them out of view.
346
+ for (const name of names.slice(0, 2)) {
347
+ const explanation = explanationFor(name)
348
+ if (explanation !== undefined) lines.push(`${explanation.where === 'README' ? 'The README says' : 'Its description says'} about ${name} (its words, not checked): "${explanation.quote}"`)
349
+ }
350
+ // Programs only for a mod that calls processes itself; hooking another's
351
+ // process.run starts nothing.
352
+ if (uses.some(item => item.kind === 'call' && /^process(?:\.|$)/.test(item.use.name))) {
353
+ // Only the modules that reach for processes, and the files they import.
354
+ const files = new Set(modules.filter(info => info.calls.some(call => /^process(?:\.|$)/.test(call.name))).flatMap(info => [info.entry, ...info.files]))
355
+ const described = describePrograms(programsOfModule(ctx, files), { buildsCode: 0, downloads: 0 }).filter(line => !line.startsWith('In '))
356
+ lines.push(...(described.length > 0 ? described.slice(0, 4) : ['Which programs it starts could not be read from its code']))
357
+ }
358
+ return lines
359
+ }
360
+
361
+ /**
362
+ * The programs a mod's own files start with a literal name, as in
363
+ * `.run(['git', ...])` or `['run'](['git'])`, outside comments, patterns and
364
+ * strings. A name built while it runs (`${tool}`) is a program chosen then.
365
+ */
366
+ function programsOfModule(ctx: ScanContext, files: ReadonlySet<string>): ProgramUse[] {
367
+ const uses: ProgramUse[] = []
368
+ for (const path of files) {
369
+ if (!/\.(?:[cm]?[jt]sx?)$/.test(path)) continue
370
+ const text = ctx.text(path)
371
+ if (text === undefined) continue
372
+ const kindAt = spanKinds(text, 'js')
373
+ for (const match of text.matchAll(/(?:\.(?:run|spawn)|\[\s*(["'])(?:run|spawn)\1\s*\])\s*\(\s*\[\s*(["'`])([^"'`\s]{1,100})\2/g)) {
374
+ const kind = kindAt(match.index ?? 0)
375
+ if (kind !== 'code') continue
376
+ const raw = match[3] as string
377
+ const dynamic = match[2] === '`' && raw.includes('${')
378
+ uses.push({ name: dynamic ? raw.slice(0, 60) : (raw.split(/[\\/]/).pop() ?? '').replace(/\.exe$/i, '').toLowerCase(), file: path, dynamic })
379
+ if (uses.length >= 500) return uses
380
+ }
381
+ }
382
+ return uses
383
+ }
@@ -13,19 +13,20 @@ export const DOWNLOAD_AND_RUN =
13
13
  /\b(?:curl|wget)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\b|\b(?:curl|wget)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:python[23]?|node|perl|ruby|php)\b|\b(?:ba|z)?sh\s+-c\s+["']?\$\(\s*(?:curl|wget)|(?:\b(?:source|(?:ba|z|da|k)?sh)|(?:^|(?<=[\s;&|(]))\.)\s+<\(\s*(?:curl|wget)|\bbase64\s+(?:-d|--decode|-D)\b[^\n|]{0,500}\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da)?sh\b|\bpython[23]?\s+-c\s+["'][^"'\n]{0,500}urllib|\beval\s+["']?\$\(\s*(?:curl|wget|echo|base64)/
14
14
 
15
15
  /**
16
- * Fetching and running a package that is not pinned to an exact version:
16
+ * Fetching and running a package that is not pinned to an exact version
17
+ * (npx, npm exec, pnpm dlx, yarn dlx, bunx, uvx, pipx run):
17
18
  * no version, a tag (@latest, @next) or a range (@^1.2) all count. An exact
18
19
  * x.y.z, with any prerelease (-rc-1) or build (+5) part, is a pin.
19
20
  */
20
21
  export const UNPINNED_RUNNER =
21
- /\b(?:npx|bunx|pnpx)\s+(?:-{1,2}[\w-]+\s+){0,5}(?!-)(?:@[\w.-]+\/)?[\w.-]+(?:@(?!\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?(?![\w.+-]))[^\s`'"();&|]*)?(?![@\w./-])|\b(?:uvx|pipx\s+run)\s+(?!\S+(?:==|@)\d)/
22
+ /\b(?:npx|bunx|pnpx|npm\s+exec|pnpm\s+dlx|yarn\s+dlx|bun\s+x)\s+(?:-{1,2}[\w-]+(?:=\S+)?\s+){0,5}(?!-)(?:@[\w.-]+\/)?[\w.-]+(?:@(?!\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?(?![\w.+-]))[^\s`'"();&|]*)?(?![@\w./-])|\b(?:uvx|pipx\s+run)\s+(?!\S+(?:==|@)\d)/
22
23
 
23
24
  /** Shell `eval` and piping into a shell. */
24
25
  export const SHELL_EVAL = /(?:^|[\s;&|(])eval\s|\|\s*(?:sudo\s+)?(?:\/[\w.\/-]*\/)?(?:env\s+)?(?:ba|z|da|k)?sh\s*(?:$|[;&|)])/m
25
26
 
26
27
  /** Places that hold credentials. */
27
28
  export const CREDENTIAL_PATH =
28
- /\.ssh(?:\/|\b)|\bid_(?:rsa|dsa|ecdsa|ed25519)\b|\.aws\/(?:credentials|config)\b|\.(?:netrc|git-credentials|pypirc)\b|\.npmrc\b|\.docker\/config\.json|\.kube\/config\b|\.config\/gh\/hosts|\.gnupg\b|\.claude\/\.credentials|Library\/Keychains|\bsecurity\s+(?:find|dump)-(?:generic|internet)-password|\bdump-keychain\b|\bLogin Data\b|Cookies\.binarycookies|\/Google\/Chrome\/|\.mozilla\/firefox|BraveSoftware|Microsoft\/Edge\//
29
+ /\.ssh(?:\/|\b)|\bid_(?:rsa|dsa|ecdsa|ed25519)\b|\.aws\/(?:credentials|config)\b|\.(?:netrc|git-credentials|pypirc)\b|\.npmrc\b|\.docker\/config\.json|\.kube\/config\b|\.config\/gh\/hosts|\.gnupg\b|\.claude\/\.credentials|Library\/Keychains|\bsecurity\s+(?:find|dump)-(?:generic|internet)-password|\bdump-keychain\b|\bLogin Data\b|Cookies\.binarycookies|(?:Application Support|\.config)\/(?:Google\/Chrome|google-chrome|chromium|BraveSoftware|Microsoft Edge)\b|\/(?:Google\/Chrome|Chromium|BraveSoftware\/Brave-Browser|Microsoft\/Edge)\/User Data\b|\.mozilla\/firefox/
29
30
 
30
31
  /** Environment variables that hold secrets, and dumps of the whole environment. */
31
32
  export const SECRET_ENV =
@@ -0,0 +1,150 @@
1
+ // Turns "this code runs programs" into what a person can judge: which
2
+ // programs, what each one is for, and which deserve a closer look.
3
+
4
+ /** One place code starts a program. `dynamic` when the program is a variable. */
5
+ export type ProgramUse = { name: string; file: string; dynamic: boolean }
6
+
7
+ type Kind = { label: string; risky: boolean }
8
+
9
+ // Written as patterns, which the guard's own scan reads as inert, not as
10
+ // names of tools it runs.
11
+ // Written as patterns with an escape, which the guard's own scan reads as
12
+ // inert rather than as names of tools it runs.
13
+ const KINDS: readonly { match: RegExp; kind: Kind }[] = [
14
+ { match: /^(?:sh|bash|zsh|dash|ksh|fish|csh|tcsh|cmd|powershell|pwsh)(?:\.exe)?$/, kind: { label: 'a shell, which can run any command', risky: true } },
15
+ { match: /^(?:swiftc|swift|gcc|g\+\+|clang|clang\+\+|cc|c\+\+|rustc|cargo|go|javac|make|cmake|tsc|esbuild)(?:\.exe)?$/, kind: { label: 'builds code, which can then run', risky: true } },
16
+ { match: /^(?:node|python|python3|deno|bun|ruby|perl|php|java)(?:\.exe)?$/, kind: { label: 'runs other scripts', risky: false } },
17
+ { match: /^(?:curl|wget|ssh|scp|sftp|rsync|nc|ncat|netcat|socat|ftp|telnet)(?:\.exe)?$/, kind: { label: 'talks to the network', risky: true } },
18
+ { match: /^(?:npm|npx|pnpm|pnpx|yarn|bunx|pip|pip3|pipx|uv|uvx|brew|apt|apt-get|gem)(?:\.exe)?$/, kind: { label: 'installs or fetches packages', risky: true } },
19
+ { match: /^(?:sudo|doas|su)(?:\.exe)?$/, kind: { label: 'runs as administrator', risky: true } },
20
+ { match: /^(?:security)(?:\.exe)?$/, kind: { label: 'reads the macOS keychain', risky: true } },
21
+ { match: /^(?:osascript)(?:\.exe)?$/, kind: { label: 'controls other apps', risky: true } },
22
+ { match: /^(?:launchctl|systemctl|crontab|schtasks)(?:\.exe)?$/, kind: { label: 'sets up background jobs', risky: true } },
23
+ { match: /^(?:ffmpeg|ffprobe|ffplay|sox|magick|convert|identify|exiftool|gm|vips)(?:\.exe)?$/, kind: { label: 'video, audio and image tools', risky: false } },
24
+ { match: /^(?:whisper|whisper-cli|whisper-cpp)(?:\.exe)?$/, kind: { label: 'speech to text', risky: false } },
25
+ { match: /^(?:git|gh)(?:\.exe)?$/, kind: { label: 'git', risky: false } },
26
+ { match: /^(?:rm|rmdir|del|shred)(?:\.exe)?$/, kind: { label: 'deletes files', risky: false } },
27
+ {
28
+ match: /^(?:cp|mv|mkdir|chmod|chown|ln|tar|unzip|zip|gzip|gunzip|touch|cat|ls|find|xargs|sed|awk|grep|head|tail|sort|which|du|df)$/,
29
+ kind: { label: 'file housekeeping', risky: false },
30
+ },
31
+ { match: /^(?:open|xdg-open|start|explorer)(?:\.exe)?$/, kind: { label: 'opens files or links', risky: false } },
32
+ { match: /^(?:chrome|google-chrome|chromium|chromium-browser|firefox|playwright)(?:\.exe)?$/, kind: { label: 'a web browser', risky: false } },
33
+ ]
34
+
35
+ const KIND_OF = { get: (name: string): Kind | undefined => KINDS.find(entry => entry.match.test(name))?.kind }
36
+
37
+ const JS_CALLS =
38
+ /(?<![\w$.])(?:execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)\s*\(\s*(?:(["'`])([^"'`\n]{1,200})|([A-Za-z_$][\w$.]{0,80}))|\b(?:child_process|childProcess|cp)\.(execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)\s*\(\s*(?:(["'`])([^"'`\n]{1,200})|([A-Za-z_$][\w$.]{0,80}))|\bBun\.spawn(?:Sync)?\s*\(\s*\[\s*(["'`])([^"'`\n]{1,200})|\bnew\s+Deno\.Command\s*\(\s*(["'`])([^"'`\n]{1,200})|\bDeno\.run\s*\(\s*\{\s*cmd\s*:\s*\[\s*(["'`])([^"'`\n]{1,200})/g
39
+
40
+ const PY_CALLS =
41
+ /\bsubprocess\.(?:run|call|Popen|check_output|check_call|getoutput|getstatusoutput)\s*\(\s*(?:\[\s*[rbfu]?(["'])([^"'\n]{1,200})|\[\s*([A-Za-z_][\w.]{0,80})|[rbfu]?(["'])([^"'\n]{1,200})|([A-Za-z_][\w.]{0,80}))|\bos\.(?:system|popen)\s*\(\s*[rbfu]?(["'])([^"'\n]{1,200})|\bpty\.spawn\s*\(\s*(?:\[\s*)?[rbfu]?(["'])([^"'\n]{1,200})|\bos\.(?:spawn|exec)[a-z]{0,4}\s*\(\s*(?:os\.P_\w+\s*,\s*)?(?:[rbfu]?(["'])([^"'\n]{1,200})|([A-Za-z_][\w.]{0,80}))/g
42
+
43
+ /** The programs a file starts, from its calls; `keep` drops matches in comments. */
44
+ export function programsIn(path: string, text: string, language: 'js' | 'python' | 'shell' | 'other', keep: (index: number) => boolean): ProgramUse[] {
45
+ const uses: ProgramUse[] = []
46
+ const patterns = language === 'js' ? [JS_CALLS] : language === 'python' ? [PY_CALLS] : language === 'shell' ? [] : [JS_CALLS, PY_CALLS]
47
+ for (const pattern of patterns) {
48
+ for (const match of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
49
+ if (uses.length >= 500) return uses
50
+ const index = match.index ?? 0
51
+ if (!keep(index)) continue
52
+ const groups = match.slice(1)
53
+ // The literal is the group after a quote; a variable has no quote before it.
54
+ let literal: string | undefined
55
+ let variable: string | undefined
56
+ for (let i = 0; i < groups.length; i += 1) {
57
+ const value = groups[i]
58
+ if (value === undefined) continue
59
+ if (/^["'`]$/.test(value)) {
60
+ literal = groups[i + 1]
61
+ break
62
+ }
63
+ if (/^(?:execFileSync|execFile|spawnSync|spawn|execSync|exec|fork)$/.test(value)) continue
64
+ variable = value
65
+ break
66
+ }
67
+ if (literal !== undefined) {
68
+ // A shell command line (execSync, os.system, a string to subprocess)
69
+ // starts with its program; a list or execFile names the program alone.
70
+ const call = /(\w+)\s*\(\s*\[?\s*[rbfu]?["'`]?$/.exec(match[0].slice(0, match[0].length - literal.length))?.[1] ?? ''
71
+ const listForm = /\(\s*\[/.test(match[0])
72
+ const isLine = !listForm && (/^(?:execSync|exec|system|popen|getoutput|getstatusoutput)$/.test(call) || match[0].startsWith('subprocess.'))
73
+ const raw = (isLine ? literal.trim().split(/\s+/)[0] : literal) ?? ''
74
+ // A name built while it runs (`${tool}`, f"{tool}") is a program chosen at run time.
75
+ const fString = /[^\w]f["']$|^f["']/.test(match[0].slice(0, match[0].length - literal.length).slice(-2))
76
+ if (raw.includes('${') || (fString && raw.includes('{'))) {
77
+ uses.push({ name: raw.slice(0, 60), file: path, dynamic: true })
78
+ continue
79
+ }
80
+ const name = normalise(raw)
81
+ if (name !== '') uses.push({ name, file: path, dynamic: false })
82
+ } else if (variable !== undefined) {
83
+ // The running interpreter itself is node or python, not an unknown program.
84
+ const itself = variable === 'process.execPath' ? 'node' : variable === 'sys.executable' ? 'python' : undefined
85
+ uses.push({ name: itself ?? variable, file: path, dynamic: itself === undefined })
86
+ }
87
+ }
88
+ }
89
+ return uses
90
+ }
91
+
92
+ function normalise(program: string): string {
93
+ const base = program.replace(/^["']|["']$/g, '').split(/[\\/]/).pop() ?? ''
94
+ return base.replace(/\.exe$/i, '').toLowerCase().slice(0, 60)
95
+ }
96
+
97
+ function plural(count: number, word: string): string {
98
+ return `${count} ${word}${count === 1 ? '' : 's'}`
99
+ }
100
+
101
+ /**
102
+ * The plain-language lines for a row about running programs: a headline, then
103
+ * the programs worth a look, then the rest by what they are for.
104
+ */
105
+ export function describePrograms(uses: readonly ProgramUse[], extra: { buildsCode: number; downloads: number }): string[] {
106
+ const counts = new Map<string, number>()
107
+ const files = new Set<string>()
108
+ const dynamic = new Map<string, number>()
109
+ for (const use of uses) {
110
+ files.add(use.file)
111
+ if (use.dynamic) dynamic.set(use.name, (dynamic.get(use.name) ?? 0) + 1)
112
+ else counts.set(use.name, (counts.get(use.name) ?? 0) + 1)
113
+ }
114
+ const byCount = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
115
+ const risky = byCount.filter(([name]) => KIND_OF.get(name)?.risky === true)
116
+ const ordered = [...risky, ...byCount.filter(([name]) => KIND_OF.get(name)?.risky !== true)]
117
+
118
+ const parts: string[] = []
119
+ if (extra.downloads > 0) parts.push('downloads code and runs it')
120
+ if (extra.buildsCode > 0) parts.push('builds code from text and runs it')
121
+ if (ordered.length > 0) {
122
+ const names = ordered.slice(0, 6).map(([name]) => name)
123
+ parts.push(`starts ${names.join(', ')}${ordered.length > 6 ? ` and ${ordered.length - 6} more` : ''}`)
124
+ }
125
+ if (dynamic.size > 0) parts.push('starts programs chosen while it runs')
126
+ if (parts.length === 0) return []
127
+ const headline = parts.join('; ')
128
+ const lines = [headline.charAt(0).toUpperCase() + headline.slice(1)]
129
+
130
+ const lookAt: string[] = []
131
+ if (extra.downloads > 0) lookAt.push(`downloads code and runs it (${plural(extra.downloads, 'place')})`)
132
+ if (extra.buildsCode > 0) lookAt.push(`builds code from text and runs it, as eval does (${plural(extra.buildsCode, 'place')})`)
133
+ for (const [name, count] of risky) lookAt.push(`${name}: ${KIND_OF.get(name)?.label} (${plural(count, 'place')})`)
134
+ if (dynamic.size > 0) lookAt.push(`a program named by ${[...dynamic.keys()].slice(0, 4).join(', ')}, chosen while it runs (${plural([...dynamic.values()].reduce((a, b) => a + b, 0), 'place')})`)
135
+ if (lookAt.length > 0) lines.push(`Worth a look: ${lookAt.join('; ')}`)
136
+
137
+ const groups = new Map<string, string[]>()
138
+ for (const [name, count] of byCount) {
139
+ const kind = KIND_OF.get(name)
140
+ if (kind?.risky === true) continue
141
+ const label = kind?.label ?? 'other programs'
142
+ groups.set(label, [...(groups.get(label) ?? []), `${name} (${count})`])
143
+ }
144
+ for (const [label, names] of groups) lines.push(`${label.charAt(0).toUpperCase()}${label.slice(1)}: ${names.join(', ')}`)
145
+ if (files.size > 0) {
146
+ const list = [...files].sort()
147
+ lines.push(`In ${plural(list.length, 'file')}: ${list.slice(0, 4).join(', ')}${list.length > 4 ? ` and ${list.length - 4} more` : ''}`)
148
+ }
149
+ return lines
150
+ }