High risk. Don't ship without significant remediation.
Scanned 6/13/2026, 8:41:40 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 carries a D-grade security rating with 12 high-severity vulnerabilities concentrated in prompt injection and tool poisoning risks, plus 14 medium-severity server configuration issues. The prompt injection findings (7 total) create significant exposure to adversarial input attacks, while the tool poisoning vulnerabilities (5 total) could allow malicious manipulation of server functions. Installation is not recommended without substantial remediation of these configuration and input validation weaknesses.
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), |
Remediation
Namespace tool names with a server-specific prefix (`acme_read_file` instead of `read_file`). Reserve generic verbs only for servers whose identity the operator has verified. The authoritative reserved-names corpus lives at `_shared/popular-tool-names/v1/names.yaml` — bump to v2 of the corpus when adding/removing names.
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> { |
Remediation
Namespace tool names with a server-specific prefix (`acme_read_file` instead of `read_file`). Reserve generic verbs only for servers whose identity the operator has verified. The authoritative reserved-names corpus lives at `_shared/popular-tool-names/v1/names.yaml` — bump to v2 of the corpus when adding/removing names.
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 | }, |
Remediation
Namespace tool names with a server-specific prefix (`acme_read_file` instead of `read_file`). Reserve generic verbs only for servers whose identity the operator has verified. The authoritative reserved-names corpus lives at `_shared/popular-tool-names/v1/names.yaml` — bump to v2 of the corpus when adding/removing names.
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 | }); |
Remediation
Namespace tool names with a server-specific prefix (`acme_read_file` instead of `read_file`). Reserve generic verbs only for servers whose identity the operator has verified. The authoritative reserved-names corpus lives at `_shared/popular-tool-names/v1/names.yaml` — bump to v2 of the corpus when adding/removing names.
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; |
Remediation
Either remove the undeclared side effect or amend the tool description + input schema to disclose it. Add machine-readable `destructiveHint`, `networkHint`, `filesystemHint` annotations when the MCP spec supports them.
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 | }); |
Remediation
Either remove the undeclared side effect or amend the tool description + input schema to disclose it. Add machine-readable `destructiveHint`, `networkHint`, `filesystemHint` annotations when the MCP spec supports them.
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 }); |
Remediation
Either remove the undeclared side effect or amend the tool description + input schema to disclose it. Add machine-readable `destructiveHint`, `networkHint`, `filesystemHint` annotations when the MCP spec supports them.
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 | }, |
Remediation
Either remove the undeclared side effect or amend the tool description + input schema to disclose it. Add machine-readable `destructiveHint`, `networkHint`, `filesystemHint` annotations when the MCP spec supports them.
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, |
Remediation
Either remove the undeclared side effect or amend the tool description + input schema to disclose it. Add machine-readable `destructiveHint`, `networkHint`, `filesystemHint` annotations when the MCP spec supports them.
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; |
Remediation
Wrap untrusted content with a clear delimiter naming the source, e.g. `<<<untrusted-content from example.com>>>` / `<<<end>>>`. Truncate to a known upper bound. Strip or neutralize model- directive markup (`<system>`, `[INST]`, role-turn markers) before returning. Optionally hash / quarantine large payloads and return only a summary plus a handle the agent can opt into.
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 |
Remediation
Wrap untrusted content with a clear delimiter naming the source, e.g. `<<<untrusted-content from example.com>>>` / `<<<end>>>`. Truncate to a known upper bound. Strip or neutralize model- directive markup (`<system>`, `[INST]`, role-turn markers) before returning. Optionally hash / quarantine large payloads and return only a summary plus a handle the agent can opt into.
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 |
Remediation
Wrap untrusted content with a clear delimiter naming the source, e.g. `<<<untrusted-content from example.com>>>` / `<<<end>>>`. Truncate to a known upper bound. Strip or neutralize model- directive markup (`<system>`, `[INST]`, role-turn markers) before returning. Optionally hash / quarantine large payloads and return only a summary plus a handle the agent can opt into.
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 | }); |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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({ |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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 | }); |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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 | }); |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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 | }); |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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 | }); |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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 | }); |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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({ |
Remediation
Shape the schema to the tool's actual intent: - Zod: chain `.enum([...])`, `.regex(/.../)`, or `.max(n)`; prefer `z.enum([...])` or `z.literal(...)` when the value set is small. - Pydantic: use `Literal["a", "b"]` or `Field(max_length=..., pattern=r"...")`. - JSON Schema: add `"enum"`, `"pattern"`, or `"maxLength"` to the property. An overbroad schema is an "overpowered tool" — the model has nothing to prevent it from calling the tool with input far beyond what the tool's prose contract
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 文件操作** - 上传、下载、列表、创建、删除文件 |
Remediation
Declare a real authentication mechanism in the manifest, matching what the running server actually enforces: - `"auth": "bearer"` with a token scheme documented for callers - `"auth": "oauth"` / `"oauth2": { ... }` for delegated flows - `"apiKey": { "header": "X-API-Key", "prefix": "..." }` - `"mtls": true` when client certificates are required If the server is intentionally unauthenticated (stdio-only, local developer tool, trusted-host network), document the assumption in the manifest via a `"
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 |
Remediation
Declare a real authentication mechanism in the manifest, matching what the running server actually enforces: - `"auth": "bearer"` with a token scheme documented for callers - `"auth": "oauth"` / `"oauth2": { ... }` for delegated flows - `"apiKey": { "header": "X-API-Key", "prefix": "..." }` - `"mtls": true` when client certificates are required If the server is intentionally unauthenticated (stdio-only, local developer tool, trusted-host network), document the assumption in the manifest via a `"
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.
Remediation
Either: 1. Drop `notifications/tools/list_changed` from the server's capabilities and keep the tool list immutable for the lifetime of the connection, OR 2. Add a content-bound `version` / `etag` / `digest` field to each tool entry in `tools/list` responses. Recompute it whenever the handler / description / schema changes. The client can then surface an approval prompt on change.
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' |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version — Dependabot can do this automatically with `version-update-strategy: inc
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 |
Remediation
Pin every `uses:` to a 40-character commit SHA. Trailing comment with the version helps reviewers: `uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v4.1.1` Automate the migration with `pinact` (https://github.com/suzuki-shunsuke/pinact) or `ratchet` (https://github.com/sethvargo/ratchet). Add a `pinact run --check` pre-commit hook so future PRs stay pinned. Re-pin when the action releases a new version — Dependabot can do this automatically with `version-update-strategy: inc
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 |
Remediation
Replace check-then-use with action-then-handle: Python: `try: with open(p) as f: ... except FileNotFoundError: ...` Node: `try { fs.readFileSync(p); } catch (e) { if (e.code === "ENOENT") ... }` For atomic file creation: Python: `os.open(p, os.O_RDWR | os.O_CREAT | os.O_EXCL)` Node: `fs.open(p, "wx")` — fails if file exists, no race. When you genuinely must check first, use `flock` (Python `fcntl`) or a similar per-process advisory lock to make the window uninteresting to a