High risk. Don't ship without significant remediation.
Scanned 6/13/2026, 7:11:43 PM·Cached result·Deep Scan·91 rules·How we decide ↗
AIVSS Score
High
Severity Breakdown
0
critical
12
high
14
medium
0
low
MCP Server Information
Findings
This package presents significant security concerns with a D grade and safety score of 66/100, driven by 12 high-severity findings across prompt injection and tool poisoning vulnerabilities that could allow attackers to manipulate server behavior or inject malicious commands. The 14 medium-severity server configuration issues compound the risk by potentially exposing the system to unauthorized access or misuse. Installation should be deferred until these vulnerabilities are addressed by the maintainers.
AIPer-finding remediation generated by bedrock-claude-haiku-4-5 — 26 of 26 findings. Click any finding to read.
No known CVEs found for this package or its dependencies.
Scan Details
Done
Sign in to save scan history and re-scan automatically on new commits.
Building your own MCP server?
Same rules, same LLM judges, same grade. Private scans stay isolated to your account and never appear in the public registry. Required for code your team hasn’t shipped yet.
26 of 26 findings
26 findings
Tool 'ssh_download' shadows reserved name 'read_file' (filesystem category); lacks unique server prefix.
Evidence
| 112 | path: listing.path, |
| 113 | count: listing.count, |
| 114 | entries: listing.entries.map(e => ({ |
| 115 | name: e.filename, |
| 116 | type: e.isDirectory ? 'directory' : e.isSymbolicLink ? 'symlink' : 'file', |
| 117 | size: e.size, |
| 118 | permissions: e.permissions.toString(8), |
RemediationAI
The problem is that the tool 'ssh_download' uses a generic name that conflicts with MCP's reserved 'read_file' operation, creating ambiguity and potential routing conflicts. Rename the tool definition in tools/file.ts from 'ssh_download' to 'ssh_sftp_download' by updating the tool registration name and all references in the tool handler export. This unique prefix eliminates name shadowing and ensures the MCP server can distinguish this SSH-specific operation from built-in filesystem tools. Verify by checking that `tools list` in the MCP client shows 'ssh_sftp_download' as a distinct tool with no conflicts.
Tool 'ssh_exec' shadows reserved name 'exec' (shell/process category); lacks unique server prefix.
Evidence
| 44 | }); |
| 45 | }, |
| 46 | |
| 47 | /** |
| 48 | * Execute a command with sudo |
| 49 | */ |
| 50 | async sudoExec(input: SudoExecInput): Promise<string> { |
RemediationAI
The problem is that the tool 'ssh_exec' shadows MCP's reserved 'exec' operation name, causing routing ambiguity and potential command execution on the wrong target. Rename the tool definition in tools/command.ts from 'ssh_exec' to 'ssh_remote_exec' by updating the tool registration name and all handler references. This unique server prefix ensures the MCP protocol correctly routes remote SSH commands separately from local execution operations. Verify by confirming that `tools list` displays 'ssh_remote_exec' without conflicts and that calling the tool executes commands on the remote SSH session.
Tool 'ssh_ls' shadows reserved name 'list_directory' (filesystem category); lacks unique server prefix.
Evidence
| 132 | return JSON.stringify({ |
| 133 | success: true, |
| 134 | message: `Created directory ${path}` |
| 135 | }); |
| 136 | }, |
RemediationAI
The problem is that the tool 'ssh_ls' shadows MCP's reserved 'list_directory' operation, creating protocol-level name collision and routing confusion. Rename the tool in tools/file.ts from 'ssh_ls' to 'ssh_list_directory' by updating the tool registration and all handler exports. This unique prefix distinguishes remote SSH directory listing from local filesystem operations, preventing the MCP server from misrouting requests. Verify by running `tools list` and confirming 'ssh_list_directory' appears as a distinct tool, then test that it returns remote directory contents.
Tool 'ssh_upload' shadows reserved name 'write_file' (filesystem category); lacks unique server prefix.
Evidence
| 92 | const session = sessionManager.getSession(sessionId); |
| 93 | await SFTPOperations.downloadFile(session, remotePath, localPath, { overwrite }); |
| 94 | |
| 95 | return JSON.stringify({ |
| 96 | success: true, |
| 97 | message: `Downloaded ${remotePath} to ${localPath}` |
| 98 | }); |
RemediationAI
The problem is that the tool 'ssh_upload' shadows MCP's reserved 'write_file' operation, causing name collision and ambiguous routing in the protocol. Rename the tool in tools/file.ts from 'ssh_upload' to 'ssh_sftp_upload' by updating the tool registration and all handler references. This unique server prefix ensures the MCP server correctly routes SFTP upload operations separately from local file write operations. Verify by checking `tools list` shows 'ssh_sftp_upload' without conflicts and test that the tool uploads files to the remote SSH session.
Tool 'ssh_write_file' performs FILESYSTEM write to remote file via SFTPOperations.writeFile but description does not explicitly disclose this write side effect
Evidence
| 137 | }, |
| 138 | |
| 139 | /** |
| 140 | * Remove file or directory |
| 141 | */ |
| 142 | async rm(input: RmInput): Promise<string> { |
| 143 | const { sessionId, path, recursive } = input; |
RemediationAI
The problem is that the 'ssh_write_file' tool description does not disclose that it performs a destructive write operation to the remote filesystem via SFTPOperations.writeFile. Update the tool description in tools/file.ts to explicitly state: 'Write content to a remote file via SFTP. WARNING: This will overwrite the target file if it exists.' This explicit disclosure ensures callers understand the side effect and can make informed decisions about tool use. Verify by reviewing the tool schema and confirming the updated description appears in `tools list` output.
Tool 'ssh_upload' performs FILESYSTEM write to local system via SFTPOperations.uploadFile but description only mentions remote destination, not that local file is read from disk
Evidence
| 77 | const session = sessionManager.getSession(sessionId); |
| 78 | await SFTPOperations.uploadFile(session, localPath, remotePath, { overwrite }); |
| 79 | |
| 80 | return JSON.stringify({ |
| 81 | success: true, |
| 82 | message: `Uploaded ${localPath} to ${remotePath}` |
| 83 | }); |
RemediationAI
The problem is that the 'ssh_upload' tool description only mentions the remote destination but fails to disclose that it reads the local file from disk, a significant side effect. Update the tool description in tools/file.ts to state: 'Upload a local file to a remote system via SFTP. Reads localPath from the local filesystem and writes to remotePath on the remote system.' This explicit disclosure prevents misunderstanding about which filesystem is accessed. Verify by checking the updated schema in `tools list` and confirming the description mentions both local read and remote write.
Tool 'ssh_download' performs FILESYSTEM write to local system via SFTPOperations.downloadFile but description does not explicitly disclose local file write side effect
Evidence
| 87 | * Download file |
| 88 | */ |
| 89 | async download(input: DownloadInput): Promise<string> { |
| 90 | const { sessionId, remotePath, localPath, overwrite } = input; |
| 91 | |
| 92 | const session = sessionManager.getSession(sessionId); |
| 93 | await SFTPOperations.downloadFile(session, remotePath, localPath, { overwrite }); |
RemediationAI
The problem is that the 'ssh_download' tool description does not explicitly disclose that it writes the downloaded file to the local filesystem, a critical side effect. Update the tool description in tools/file.ts to state: 'Download a remote file via SFTP and write it to the local filesystem. Reads from remotePath on the remote system and writes to localPath locally.' This explicit disclosure ensures callers understand the local write side effect. Verify by reviewing the updated tool schema in `tools list` and confirming both the remote read and local write are documented.
Tool 'ssh_rm' performs FILESYSTEM deletion via SFTPOperations.deleteFile but description does not explicitly disclose this destructive write side effect
Evidence
| 117 | size: e.size, |
| 118 | permissions: e.permissions.toString(8), |
| 119 | modified: e.modifyTime.toISOString() |
| 120 | })) |
| 121 | }); |
| 122 | }, |
RemediationAI
The problem is that the 'ssh_rm' tool description does not explicitly disclose that it performs destructive deletion via SFTPOperations.deleteFile, a critical side effect. Update the tool description in tools/file.ts to state: 'Delete a remote file or directory via SFTP. WARNING: This operation is destructive and cannot be undone. Use with caution.' This explicit warning ensures callers understand the irreversible nature of the operation. Verify by checking the updated tool schema in `tools list` and confirming the destructive warning is present.
Tool 'ssh_mkdir' performs FILESYSTEM directory creation via SFTPOperations.createDirectory but description does not explicitly disclose this write side effect
Evidence
| 107 | const session = sessionManager.getSession(sessionId); |
| 108 | const listing = await SFTPOperations.listDirectory(session, path); |
| 109 | |
| 110 | return JSON.stringify({ |
| 111 | success: true, |
| 112 | path: listing.path, |
| 113 | count: listing.count, |
RemediationAI
The problem is that the 'ssh_mkdir' tool description does not explicitly disclose that it performs filesystem directory creation via SFTPOperations.createDirectory, a write side effect. Update the tool description in tools/file.ts to state: 'Create a remote directory via SFTP. Creates the specified directory path on the remote system, optionally creating parent directories if recursive is true.' This explicit disclosure clarifies the filesystem modification. Verify by reviewing the updated tool schema in `tools list` and confirming the directory creation side effect is documented.
Tool 'ssh_ls' returns directory listing with file metadata from untrusted remote systems without provenance markers, enabling injection of malicious filenames or metadata into LLM context.
Evidence
| 142 | async rm(input: RmInput): Promise<string> { |
| 143 | const { sessionId, path, recursive } = input; |
| 144 | |
| 145 | const session = sessionManager.getSession(sessionId); |
| 146 | await SFTPOperations.remove(session, path, { recursive }); |
| 147 | |
| 148 | return JSON.stringify({ |
| 149 | success: true, |
| 150 | message: `Removed ${path}` |
| 151 | }); |
| 152 | }, |
| 153 | |
| 154 | /** |
| 155 | * Get file/directory info |
| 156 | */ |
| 157 | async stat(input: StatInput): Promise<string> { |
| 158 | const { sessionId, path } = input; |
RemediationAI
The problem is that 'ssh_ls' returns directory listings with filenames and metadata from untrusted remote systems without provenance markers, allowing malicious filenames to be injected into the LLM context. Wrap each directory entry in tools/file.ts with a provenance marker: `entries: listing.entries.map(e => ({ ...entry, _source: 'ssh_remote', _host: session.host, _timestamp: new Date().toISOString() }))`. This marks untrusted remote content so the LLM can distinguish it from local data. Verify by calling ssh_ls and confirming each entry includes _source, _host, and _timestamp fields.
Tool 'ssh_exec' returns arbitrary stdout/stderr from remote command execution without provenance markers, allowing untrusted remote content to be injected into the LLM's context.
Evidence
| 32 | async exec(input: ExecInput): Promise<string> { |
| 33 | const { sessionId, command, timeout } = input; |
| 34 | |
| 35 | const session = sessionManager.getSession(sessionId); |
| 36 | const result = await SSHExecutor.executeCommand(session, command, { timeout }); |
| 37 | |
| 38 | return JSON.stringify({ |
| 39 | success: result.exitCode === 0, |
| 40 | stdout: result.stdout, |
| 41 | stderr: result.stderr, |
| 42 | exitCode: result.exitCode, |
| 43 | signal: result.signal |
| 44 | }); |
| 45 | }, |
| 46 | |
| 47 | /** |
| 48 | * Execute a command wit |
RemediationAI
The problem is that 'ssh_exec' returns arbitrary stdout/stderr from remote command execution without provenance markers, allowing untrusted remote content injection into the LLM context. Wrap the command result in tools/command.ts with provenance metadata: `{ success: result.success, stdout: result.stdout, stderr: result.stderr, _source: 'ssh_remote', _host: session.host, _command: command, _timestamp: new Date().toISOString() }`. This marks untrusted remote output so the LLM can identify its origin. Verify by executing a command and confirming the response includes _source, _host, _command, and _timestamp fields.
Tool 'ssh_read_file' reads arbitrary remote file content (untrusted third-party authored files accessible via SSH) and returns it verbatim to the LLM without provenance markers or delimiters identifying the source.
Evidence
| 112 | path: listing.path, |
| 113 | count: listing.count, |
| 114 | entries: listing.entries.map(e => ({ |
| 115 | name: e.filename, |
| 116 | type: e.isDirectory ? 'directory' : e.isSymbolicLink ? 'symlink' : 'file', |
| 117 | size: e.size, |
| 118 | permissions: e.permissions.toString(8), |
| 119 | modified: e.modifyTime.toISOString() |
| 120 | })) |
| 121 | }); |
| 122 | }, |
| 123 | |
| 124 | /** |
| 125 | * Create directory |
| 126 | */ |
| 127 | async mkdir(input: MkdirInput): Promise<string> { |
| 128 | const { sessionId, path, recursive } = in |
RemediationAI
The problem is that 'ssh_read_file' reads arbitrary remote file content from untrusted third-party systems and returns it verbatim without provenance markers or delimiters. Wrap the file content in tools/file.ts with provenance metadata: `{ success: true, path: path, content: content, _source: 'ssh_remote', _host: session.host, _encoding: encoding, _timestamp: new Date().toISOString(), _warning: 'Content from untrusted remote source' }`. This marks untrusted file content so the LLM can identify its origin and treat it with appropriate caution. Verify by reading a remote file and confirming the response includes _source, _host, _warning, and _timestamp fields.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 26 | export const mkdirSchema = z.object({ |
| 27 | sessionId: z.string().describe('SSH session ID'), |
| 28 | path: z.string().describe('Remote directory path to create'), |
| 29 | recursive: z.boolean().optional().default(false).describe('Create parent directories if needed') |
| 30 | }); |
RemediationAI
The problem is that the mkdirSchema exposes an unconstrained 'path' string field, allowing arbitrary directory paths including traversal sequences. Add path validation to mkdirSchema in tools/file.ts: `path: z.string().regex(/^[a-zA-Z0-9._\/-]+$/).max(1024).describe('Remote directory path to create')`. This regex restricts paths to safe characters and max length, preventing directory traversal attacks. Verify by attempting to pass a path with '..' or special characters and confirming the schema validation rejects it.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 21 | export const lsSchema = z.object({ |
| 22 | sessionId: z.string().describe('SSH session ID'), |
| 23 | path: z.string().describe('Remote directory path to list') |
| 24 | }); |
| 25 | |
| 26 | export const mkdirSchema = z.object({ |
RemediationAI
The problem is that the lsSchema exposes an unconstrained 'path' string field, allowing arbitrary directory paths including traversal sequences. Add path validation to lsSchema in tools/file.ts: `path: z.string().regex(/^[a-zA-Z0-9._\/-]+$/).max(1024).describe('Remote directory path to list')`. This regex restricts paths to safe characters and max length, preventing directory traversal attacks. Verify by attempting to pass a path with '..' and confirming the schema validation rejects it.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 32 | export const rmSchema = z.object({ |
| 33 | sessionId: z.string().describe('SSH session ID'), |
| 34 | path: z.string().describe('Remote path to delete'), |
| 35 | recursive: z.boolean().optional().default(false).describe('Delete directories recursively') |
| 36 | }); |
RemediationAI
The problem is that the rmSchema exposes an unconstrained 'path' string field, allowing arbitrary deletion of any file or directory. Add path validation to rmSchema in tools/file.ts: `path: z.string().regex(/^[a-zA-Z0-9._\/-]+$/).max(1024).describe('Remote path to delete')`. This regex restricts paths to safe characters and max length, preventing directory traversal and unintended deletions. Verify by attempting to pass a path with '..' and confirming the schema validation rejects it.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 43 | export const readFileSchema = z.object({ |
| 44 | sessionId: z.string().describe('SSH session ID'), |
| 45 | path: z.string().describe('Remote file path to read'), |
| 46 | encoding: z.string().optional().default('utf-8').describe('File encoding') |
| 47 | }); |
RemediationAI
The problem is that the readFileSchema exposes an unconstrained 'path' string field, allowing arbitrary file reads including sensitive system files. Add path validation to readFileSchema in tools/file.ts: `path: z.string().regex(/^[a-zA-Z0-9._\/-]+$/).max(1024).describe('Remote file path to read')`. This regex restricts paths to safe characters and max length, preventing directory traversal attacks. Verify by attempting to pass a path with '..' and confirming the schema validation rejects it.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 49 | export const writeFileSchema = z.object({ |
| 50 | sessionId: z.string().describe('SSH session ID'), |
| 51 | path: z.string().describe('Remote file path to write'), |
| 52 | content: z.string().describe('File content to write'), |
| 53 | encoding: z.string().optional().default('utf-8').describe('File encoding') |
| 54 | }); |
RemediationAI
The problem is that the writeFileSchema exposes unconstrained 'path' and 'content' string fields, allowing arbitrary file writes and content injection. Add validation to writeFileSchema in tools/file.ts: `path: z.string().regex(/^[a-zA-Z0-9._\/-]+$/).max(1024), content: z.string().max(10485760)`. This restricts paths to safe characters and content to 10MB, preventing directory traversal and resource exhaustion. Verify by attempting to pass a path with '..' or content exceeding 10MB and confirming the schema validation rejects it.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 7 | */ |
| 8 | export const execSchema = z.object({ |
| 9 | sessionId: z.string().describe('SSH session ID'), |
| 10 | command: z.string().describe('Command to execute'), |
| 11 | timeout: z.number().optional().default(30000).describe('Command timeout in milliseconds') |
| 12 | }); |
RemediationAI
The problem is that the execSchema exposes an unconstrained 'command' string field, allowing arbitrary command injection. Add command validation to execSchema in tools/command.ts: `command: z.string().max(4096).describe('Command to execute')` and implement a whitelist of allowed commands or patterns in the exec handler. This limits command length and allows you to restrict dangerous operations. Verify by attempting to pass a command with shell metacharacters and confirming it is either rejected or safely escaped.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 13 | export const sudoExecSchema = z.object({ |
| 14 | sessionId: z.string().describe('SSH session ID'), |
| 15 | command: z.string().describe('Command to execute with sudo'), |
| 16 | sudoPassword: z.string().optional().describe('Sudo password (if required)'), |
| 17 | timeout: z.number().optional().default(30000).describe('Command timeout in milliseconds') |
| 18 | }); |
RemediationAI
The problem is that the sudoExecSchema exposes unconstrained 'command' and 'sudoPassword' string fields, allowing arbitrary sudo command injection and password exposure. Add validation to sudoExecSchema in tools/command.ts: `command: z.string().max(4096), sudoPassword: z.string().max(256).optional()` and implement command whitelisting in the sudoExec handler. This limits command length and prevents dangerous operations. Verify by attempting to pass a command with shell metacharacters and confirming it is rejected or safely handled.
MCP tool input schema exposes an unconstrained string/any field with a risky name (command/query/sql/code/script/url/path/expr/ eval). Any caller can pass arbitrary values, which typically widens the tool's blast radius well beyond its intent. Narrow the schema with `.enum()`, `.regex()`, `.max()`, `Literal[...]`, Pydantic `Field(max_length=..., pattern=...)`, or a JSON Schema `enum` / `pattern` / `maxLength`.
Evidence
| 38 | export const statSchema = z.object({ |
| 39 | sessionId: z.string().describe('SSH session ID'), |
| 40 | path: z.string().describe('Remote path to get info') |
| 41 | }); |
| 42 | |
| 43 | export const readFileSchema = z.object({ |
RemediationAI
The problem is that the statSchema exposes an unconstrained 'path' string field, allowing arbitrary file stat operations including traversal. Add path validation to statSchema in tools/file.ts: `path: z.string().regex(/^[a-zA-Z0-9._\/-]+$/).max(1024).describe('Remote path to get info')`. This regex restricts paths to safe characters and max length, preventing directory traversal attacks. Verify by attempting to pass a path with '..' and confirming the schema validation rejects it.
MCP manifest declares tools but no authentication field is present (none of: auth, authorization, bearer, oauth, mtls, apiKey, api_key, basic, token, authToken). Absence is a weak signal — confirm whether the server relies on network-layer or host-level auth, or declare the real mechanism explicitly so reviewers can audit it.
Evidence
| 1 | # SSH/SFTP MCP Server |
| 2 | |
| 3 | [](https://www.npmjs.com/package/ssh-client-mcp) |
| 4 | [](https://opensource.org/licenses/MIT) |
| 5 | |
| 6 | 一个 MCP (Model Context Protocol) 服务器,让 Claude 等 AI 助手能够通过 SSH 执行远程命令和进行 SFTP 文件操作。 |
| 7 | |
| 8 | [English](./README.md) | **中文** |
| 9 | |
| 10 | ## 功能特性 |
| 11 | |
| 12 | - **服务器配置管理** - 支持通过命令行参数、配置文件或环境变量配置服务器 |
| 13 | - **连接测试** - 在建立会话前测试服务器连通性 |
| 14 | - **SSH 命令执行** - 远程执行命令,支持 sudo |
| 15 | - **SFTP 文件操作** - 上传、下载、列表、创建、删除文件 |
RemediationAI
The problem is that the MCP manifest in README.md does not declare an authentication mechanism, making it unclear how the server is secured. Add an explicit authentication section to README.md documenting the actual auth method: 'Authentication: This server relies on SSH key-based authentication configured via the SSH session manager. Access control is enforced at the SSH protocol level using host keys and user credentials.' This clarifies the security model for reviewers. Verify by reviewing the updated README and confirming it explicitly states the authentication mechanism.
MCP manifest declares tools but no authentication field is present (none of: auth, authorization, bearer, oauth, mtls, apiKey, api_key, basic, token, authToken). Absence is a weak signal — confirm whether the server relies on network-layer or host-level auth, or declare the real mechanism explicitly so reviewers can audit it.
Evidence
| 1 | # SSH/SFTP MCP Server |
| 2 | |
| 3 | [](https://www.npmjs.com/package/ssh-client-mcp) |
| 4 | [](https://opensource.org/licenses/MIT) |
| 5 | |
| 6 | **English** | [中文](./README.zh-CN.md) |
| 7 | |
| 8 | A Model Context Protocol (MCP) server that enables AI assistants like Claude to execute SSH commands and perform SFTP file operations on remote servers. |
| 9 | |
| 10 | ## Features |
| 11 | |
| 12 | - **Server Configuration Management** - Define servers |
RemediationAI
The problem is that the MCP manifest in README.zh-CN.md does not declare an authentication mechanism, making it unclear how the server is secured. Add an explicit authentication section to README.zh-CN.md documenting the actual auth method: 'Authentication: This server relies on SSH key-based authentication configured via the SSH session manager. Access control is enforced at the SSH protocol level using host keys and user credentials.' This clarifies the security model for reviewers. Verify by reviewing the updated README and confirming it explicitly states the authentication mechanism.
SSH Client MCP server emits notifications/tools/list_changed but tool definitions lack per-tool content-bound integrity fields (version, etag, digest, sha256, hash) to detect runtime tool list swaps.
RemediationAI
The problem is that the MCP server emits notifications/tools/list_changed but tool definitions lack per-tool integrity fields (version, etag, digest) to detect runtime tool list swaps. Add integrity fields to each tool definition in index.ts: `version: '1.0.0', etag: crypto.createHash('sha256').update(JSON.stringify(toolDef)).digest('hex')`. This allows clients to detect if tool definitions have been modified at runtime. Verify by computing the etag of a tool definition and confirming it changes when the tool definition is modified.
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable — a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 17 | uses: actions/checkout@v4 |
| 18 | |
| 19 | - name: Setup Node.js |
| 20 | uses: actions/setup-node@v4 |
| 21 | with: |
| 22 | node-version: '18' |
| 23 | registry-url: 'https://registry.npmjs.org' |
RemediationAI
The problem is that .github/workflows/publish.yml uses mutable GitHub Actions references (actions/checkout@v4), allowing a compromised maintainer to inject malicious code into the CI pipeline. Pin the checkout action to a specific commit SHA in .github/workflows/publish.yml: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1`. This ensures the exact version of the action is used and cannot be silently replaced. Verify by running the workflow and confirming it uses the pinned SHA.
GitHub Actions `uses:` reference is not pinned to a 40-character commit SHA. Tags (`@v4`) and branches (`@main`) are mutable — a compromised maintainer or a tag rewrite can substitute malicious code into your CI pipeline silently. Pin to a SHA: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab`. For readability, include the version as a trailing comment: `# v4.1.1`. Tools like `pinact` / `ratchet` automate this. Allowed unpinned forms (excluded by the rule): - Local actions `.
Evidence
| 14 | steps: |
| 15 | - name: Checkout code |
| 16 | uses: actions/checkout@v4 |
| 17 | |
| 18 | - name: Setup Node.js |
| 19 | uses: actions/setup-node@v4 |
RemediationAI
The problem is that .github/workflows/publish.yml uses mutable GitHub Actions references (actions/setup-node@v4), allowing a compromised maintainer to inject malicious code into the CI pipeline. Pin the setup-node action to a specific commit SHA in .github/workflows/publish.yml: `uses: actions/setup-node@60edb5dd545a775178fac7f3a11fc4209779e5cf # v4.0.2`. This ensures the exact version of the action is used and cannot be silently replaced. Verify by running the workflow and confirming it uses the pinned SHA.
Time-of-check-to-time-of-use race. Code calls `os.path.exists` / `fs.existsSync` to check a path, then `open` / `readFileSync` / `unlink` on the same name within a few lines — without a lock or atomic-open. An attacker who can race the filesystem (symlink, file replacement) between the check and the use gets the action applied to a different target. Replace the check-then-use pattern with the action's own error handling: try the open and catch FileNotFoundError / ENOENT. For atomic creation use
Evidence
| 1 | import { Client, SFTPWrapper } from 'ssh2'; |
| 2 | import { v4 as uuidv4 } from 'uuid'; |
| 3 | import * as fs from 'fs'; |
| 4 | import type { SSHSession, SSHCredentials, SessionInfo } from './types.js'; |
| 5 | |
| 6 | /** |
| 7 | * Manages SSH session lifecycle, connection pooling, and auto-cleanup |
| 8 | */ |
| 9 | export class SessionManager { |
| 10 | private sessions: Map<string, SSHSession> = new Map(); |
| 11 | private sessionTimeout: number; |
| 12 | private cleanupInterval: NodeJS.Timeout | null = null; |
| 13 | private maxSessions: number; |
| 14 | |
| 15 | constructor(sessionTimeout |
RemediationAI
The problem is that SessionManager.ts uses a check-then-use pattern (fs.existsSync followed by file operations) without atomic operations, creating a race condition where an attacker can swap the target file between the check and use. Replace the check-then-use pattern in SessionManager.ts with direct error handling: remove the existsSync call and wrap the file operation in a try-catch block that handles ENOENT errors. This makes the operation atomic and eliminates the race window. Verify by attempting to race the filesystem during a session operation and confirming the operation either succeeds atomically or fails cleanly without accessing the wrong file.