ai-install-guard 0.0.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 +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- 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 +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { HookCommand, ScanContext } from './context'
|
|
2
|
+
import {
|
|
3
|
+
CLAUDE_LOAD_DIR,
|
|
4
|
+
CLAUDE_SETTINGS,
|
|
5
|
+
CODE_FROM_TEXT,
|
|
6
|
+
NETWORK_CODE,
|
|
7
|
+
PROCESS_CODE,
|
|
8
|
+
PYTHON_CODE_FROM_TEXT,
|
|
9
|
+
PYTHON_PROCESS,
|
|
10
|
+
CREDENTIAL_PATH,
|
|
11
|
+
DOWNLOAD_AND_RUN,
|
|
12
|
+
NETWORK_COMMAND,
|
|
13
|
+
PERSISTENCE_TARGET,
|
|
14
|
+
SECRET_ENV,
|
|
15
|
+
SHELL_EVAL,
|
|
16
|
+
UNPINNED_RUNNER,
|
|
17
|
+
WRITE_CODE,
|
|
18
|
+
WRITE_SHELL,
|
|
19
|
+
} from './patterns'
|
|
20
|
+
import { languageOf, secretName } from './code'
|
|
21
|
+
import { promptConcerns } from './prompts'
|
|
22
|
+
import { findAll, findFirst, spanKinds, type Match } from './text'
|
|
23
|
+
import { row, type CheckResult, type Evidence } from './types'
|
|
24
|
+
|
|
25
|
+
const GROUP = 'hooks' as const
|
|
26
|
+
|
|
27
|
+
/** What makes a shell hook a FAIL, in the order the report names them. */
|
|
28
|
+
const DANGERS: readonly { pattern: RegExp; what: string }[] = [
|
|
29
|
+
{ pattern: DOWNLOAD_AND_RUN, what: 'downloads or decodes code and runs it' },
|
|
30
|
+
{ pattern: UNPINNED_RUNNER, what: 'fetches and runs an unpinned package' },
|
|
31
|
+
{ pattern: NETWORK_COMMAND, what: 'uses a network tool' },
|
|
32
|
+
{ pattern: SHELL_EVAL, what: 'evaluates text as shell code' },
|
|
33
|
+
{ pattern: CREDENTIAL_PATH, what: 'touches a credential file' },
|
|
34
|
+
{ pattern: SECRET_ENV, what: 'reads secret environment variables' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/** Hook output that approves a tool call without asking you. */
|
|
38
|
+
const AUTO_APPROVE = /["']?permissionDecision["']?\s*[:=]\s*["']allow["']|\bdecision["']?\s*:\s*["']approve["']|\bpermissionDecision\b[^\n]{0,40}\ballow\b/
|
|
39
|
+
|
|
40
|
+
export function hookChecks(ctx: ScanContext): CheckResult[] {
|
|
41
|
+
if (ctx.hookCommands.length === 0) return []
|
|
42
|
+
const commands: Evidence[] = []
|
|
43
|
+
const approvals: Evidence[] = []
|
|
44
|
+
|
|
45
|
+
for (const hook of ctx.hookCommands) {
|
|
46
|
+
const where = `${hook.event}${hook.matcher === undefined || hook.matcher === '' ? '' : ` (${hook.matcher})`}`
|
|
47
|
+
if (hook.type === 'command') {
|
|
48
|
+
const dangers = dangersIn(hook.command)
|
|
49
|
+
commands.push({
|
|
50
|
+
file: hook.file,
|
|
51
|
+
line: hook.line,
|
|
52
|
+
excerpt: `on ${where}: ${hook.command}${dangers.length > 0 ? ` [${dangers.join('; ')}]` : ''}`,
|
|
53
|
+
status: dangers.length > 0 ? 'fail' : 'warn',
|
|
54
|
+
})
|
|
55
|
+
const approval = findFirst(hook.command, AUTO_APPROVE)
|
|
56
|
+
if (approval !== undefined) approvals.push({ file: hook.file, line: hook.line, excerpt: approval.excerpt, status: 'fail' })
|
|
57
|
+
} else if (hook.type === 'http') {
|
|
58
|
+
const isLocal = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/.test(hook.command)
|
|
59
|
+
commands.push({
|
|
60
|
+
file: hook.file,
|
|
61
|
+
line: hook.line,
|
|
62
|
+
excerpt: `on ${where}: sends each event, with tool inputs and outputs, to ${hook.command}`,
|
|
63
|
+
status: isLocal ? 'warn' : 'fail',
|
|
64
|
+
})
|
|
65
|
+
} else {
|
|
66
|
+
// A prompt or agent hook is instructions the model follows: judged as a prompt.
|
|
67
|
+
const concerns = promptConcerns(hook.command)
|
|
68
|
+
commands.push({
|
|
69
|
+
file: hook.file,
|
|
70
|
+
line: hook.line,
|
|
71
|
+
excerpt: `on ${where}: asks a model (${hook.type}): ${hook.command}${concerns.reasons.length > 0 ? ` [${concerns.reasons.join('; ')}]` : ''}`,
|
|
72
|
+
status: concerns.status,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Scripts the hooks run are part of the hook. A match in a comment is only reported.
|
|
78
|
+
for (const file of ctx.files) {
|
|
79
|
+
// Every script a hook runs, even one a hooks module also imports.
|
|
80
|
+
if (!ctx.hookScripts.has(file.path) && ctx.roleOf(file.path) !== 'hook-script') continue
|
|
81
|
+
const text = ctx.text(file.path)
|
|
82
|
+
if (text === undefined) continue
|
|
83
|
+
// Read as the interpreter the hook runs it with, not as its name suggests.
|
|
84
|
+
const kindAt = spanKinds(text, ctx.interpreters.get(file.path) ?? languageOf(file.path, text))
|
|
85
|
+
const isInert = (match: Match): boolean => ['comment', 'regex', 'trailing-comment'].includes(kindAt(match.index))
|
|
86
|
+
const found = (match: Match, what: string): Evidence => {
|
|
87
|
+
const shown = `${what}: ${what === 'reads secret environment variables' ? secretName(match) : match.excerpt}`
|
|
88
|
+
const kind = kindAt(match.index)
|
|
89
|
+
if (kind === 'comment' || kind === 'regex') return { file: file.path, line: match.line, excerpt: `${shown} (in a comment)`, status: 'info' }
|
|
90
|
+
// After code on the same line, a comment may be hiding a command: a warning at least.
|
|
91
|
+
if (kind === 'trailing-comment') return { file: file.path, line: match.line, excerpt: `${shown} (in a comment after code)`, status: 'warn' }
|
|
92
|
+
return { file: file.path, line: match.line, excerpt: shown, status: 'fail' }
|
|
93
|
+
}
|
|
94
|
+
// Gates (does the script write at all?) count every match but those in
|
|
95
|
+
// whole-line comments or real patterns: a trailing comment is no proof.
|
|
96
|
+
const live = (pattern: RegExp): boolean => findAll(text, pattern, 1, index => !['comment', 'regex'].includes(kindAt(index))).length > 0
|
|
97
|
+
// Live matches first, so comments cannot crowd out a real command.
|
|
98
|
+
const matches = (pattern: RegExp): Match[] => {
|
|
99
|
+
const all = findAll(text, pattern, 10_000)
|
|
100
|
+
return [...all.filter(match => !isInert(match)).slice(0, 3), ...all.filter(isInert).slice(0, 2)]
|
|
101
|
+
}
|
|
102
|
+
for (const { pattern, what } of DANGERS) {
|
|
103
|
+
for (const match of matches(pattern)) commands.push(found(match, what))
|
|
104
|
+
}
|
|
105
|
+
for (const match of matches(AUTO_APPROVE)) approvals.push(found(match, 'approves the tool call'))
|
|
106
|
+
const writes = live(WRITE_SHELL) || live(WRITE_CODE)
|
|
107
|
+
if (writes) {
|
|
108
|
+
for (const match of matches(PERSISTENCE_TARGET)) commands.push(found(match, 'writes to a startup or persistence location'))
|
|
109
|
+
for (const match of matches(CLAUDE_LOAD_DIR)) commands.push(found(match, 'writes where Claude Code loads plugins, skills or memory'))
|
|
110
|
+
}
|
|
111
|
+
// Reading settings is not changing them: a FAIL needs a write.
|
|
112
|
+
for (const match of matches(CLAUDE_SETTINGS)) {
|
|
113
|
+
const item = found(match, writes ? 'changes Claude Code settings or permissions' : 'reads Claude Code settings or permissions')
|
|
114
|
+
commands.push(writes || item.status === 'info' ? item : { ...item, status: 'warn' })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
row(
|
|
120
|
+
{
|
|
121
|
+
id: 'hooks.commands',
|
|
122
|
+
group: GROUP,
|
|
123
|
+
title: 'Shell hooks and the scripts they run',
|
|
124
|
+
advice: 'A classic hook runs with your permissions on every matching event, outside any mod sandbox, and cannot be blocked per plugin. Read each command; never install one that reaches the network, runs downloaded code or touches your keys.',
|
|
125
|
+
},
|
|
126
|
+
commands,
|
|
127
|
+
),
|
|
128
|
+
row(
|
|
129
|
+
{
|
|
130
|
+
id: 'hooks.auto-approve',
|
|
131
|
+
group: GROUP,
|
|
132
|
+
title: 'Hooks that approve tool calls for you',
|
|
133
|
+
advice: 'A hook that answers "allow" skips your permission prompt for every matching tool call.',
|
|
134
|
+
},
|
|
135
|
+
approvals,
|
|
136
|
+
'fail',
|
|
137
|
+
),
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Code passed inline to an interpreter (`python3 -c "..."`, `node -e "..."`). */
|
|
142
|
+
const INLINE_CODE = /\b(?:python[23]?|node|deno|bun|perl|ruby|php|osascript|pwsh|powershell)(?:\.exe)?\s+(?:-[A-Za-z]*\s+)*-(?:c|e|E|r|Command)\b/
|
|
143
|
+
|
|
144
|
+
function dangersIn(command: string): string[] {
|
|
145
|
+
const found = DANGERS.filter(({ pattern }) => pattern.test(command)).map(({ what }) => what)
|
|
146
|
+
// Inline code is judged as code: a socket, a process or a network call in it fails.
|
|
147
|
+
if (INLINE_CODE.test(command) && [NETWORK_CODE, PROCESS_CODE, PYTHON_PROCESS, CODE_FROM_TEXT, PYTHON_CODE_FROM_TEXT, /\bsocket\b/].some(pattern => pattern.test(command))) {
|
|
148
|
+
found.push('runs inline code that reaches the network or starts programs')
|
|
149
|
+
}
|
|
150
|
+
if (PERSISTENCE_TARGET.test(command) && WRITE_SHELL.test(command)) found.push('writes to a startup or persistence location')
|
|
151
|
+
if (CLAUDE_SETTINGS.test(command)) found.push('changes Claude Code settings or permissions')
|
|
152
|
+
if (CLAUDE_LOAD_DIR.test(command) && WRITE_SHELL.test(command)) found.push('writes where Claude Code loads plugins, skills or memory')
|
|
153
|
+
return found
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type { HookCommand }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Recognising files by their first bytes rather than their names.
|
|
2
|
+
|
|
3
|
+
export type Magic = 'elf' | 'mach-o' | 'pe' | 'wasm' | 'zip' | 'gzip' | 'bzip2' | 'xz' | '7z' | 'rar' | 'zstd' | 'tar' | 'png' | 'jpeg' | 'gif' | 'webp' | 'pdf'
|
|
4
|
+
|
|
5
|
+
export const EXECUTABLE: ReadonlySet<Magic> = new Set(['elf', 'mach-o', 'pe', 'wasm'])
|
|
6
|
+
export const ARCHIVE: ReadonlySet<Magic> = new Set(['zip', 'gzip', 'bzip2', 'xz', '7z', 'rar', 'zstd', 'tar'])
|
|
7
|
+
export const IMAGE_EXTENSIONS: Record<string, Magic> = { png: 'png', jpg: 'jpeg', jpeg: 'jpeg', gif: 'gif', webp: 'webp' }
|
|
8
|
+
export const NATIVE_EXTENSIONS = new Set(['so', 'dylib', 'dll', 'node', 'exe', 'o', 'a', 'pyc', 'pyo', 'class', 'jar', 'wasm'])
|
|
9
|
+
export const TEXT_EXTENSIONS = new Set(['md', 'mdx', 'txt', 'json', 'jsonc', 'yaml', 'yml', 'toml', 'ini', 'env', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'sh', 'bash', 'zsh', 'rb', 'html', 'css', 'svg', 'xml', 'csv'])
|
|
10
|
+
export const ARCHIVE_EXTENSIONS = new Set(['zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'zst', 'tar', 'jar', 'whl', 'docx', 'xlsx', 'pptx', 'skill'])
|
|
11
|
+
|
|
12
|
+
export function magicOf(bytes: Uint8Array): Magic | undefined {
|
|
13
|
+
const at = (offset: number, ...values: number[]): boolean => values.every((value, i) => bytes[offset + i] === value)
|
|
14
|
+
if (at(0, 0x7f, 0x45, 0x4c, 0x46)) return 'elf'
|
|
15
|
+
if (at(0, 0xfe, 0xed, 0xfa, 0xce) || at(0, 0xfe, 0xed, 0xfa, 0xcf) || at(0, 0xce, 0xfa, 0xed, 0xfe) || at(0, 0xcf, 0xfa, 0xed, 0xfe) || at(0, 0xca, 0xfe, 0xba, 0xbe)) return 'mach-o'
|
|
16
|
+
if (at(0, 0x4d, 0x5a) && isPortableExecutable(bytes)) return 'pe'
|
|
17
|
+
if (at(0, 0x00, 0x61, 0x73, 0x6d)) return 'wasm'
|
|
18
|
+
if (at(0, 0x50, 0x4b, 0x03, 0x04) || at(0, 0x50, 0x4b, 0x05, 0x06)) return 'zip'
|
|
19
|
+
if (at(0, 0x1f, 0x8b)) return 'gzip'
|
|
20
|
+
if (at(0, 0x42, 0x5a, 0x68)) return 'bzip2'
|
|
21
|
+
if (at(0, 0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00)) return 'xz'
|
|
22
|
+
if (at(0, 0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c)) return '7z'
|
|
23
|
+
if (at(0, 0x52, 0x61, 0x72, 0x21)) return 'rar'
|
|
24
|
+
if (at(0, 0x28, 0xb5, 0x2f, 0xfd)) return 'zstd'
|
|
25
|
+
if (at(257, 0x75, 0x73, 0x74, 0x61, 0x72)) return 'tar'
|
|
26
|
+
if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'png'
|
|
27
|
+
if (at(0, 0xff, 0xd8, 0xff)) return 'jpeg'
|
|
28
|
+
if (at(0, 0x47, 0x49, 0x46, 0x38)) return 'gif'
|
|
29
|
+
if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return 'webp'
|
|
30
|
+
if (at(0, 0x25, 0x50, 0x44, 0x46)) return 'pdf'
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An MZ header whose e_lfanew field points at a `PE\0\0` signature, not just text that starts with "MZ". */
|
|
35
|
+
function isPortableExecutable(bytes: Uint8Array): boolean {
|
|
36
|
+
if (bytes.length < 0x40) return false
|
|
37
|
+
const offset = ((bytes[0x3c] as number) | ((bytes[0x3d] as number) << 8) | ((bytes[0x3e] as number) << 16) | ((bytes[0x3f] as number) << 24)) >>> 0
|
|
38
|
+
return offset + 4 <= bytes.length && bytes[offset] === 0x50 && bytes[offset + 1] === 0x45 && bytes[offset + 2] === 0 && bytes[offset + 3] === 0
|
|
39
|
+
}
|
|
40
|
+
|
|
@@ -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
|
+
}
|