From f8695b079579d556d3b64d1e142ed1d55e72897d Mon Sep 17 00:00:00 2001 From: jettwang Date: Sat, 13 Jun 2026 00:43:57 +0800 Subject: [PATCH] fix(password): don't print secret to a terminal on --password-get --password-get dumped the stored password in plaintext to stdout by default, leaving it in terminal scrollback. sshx already uses the keyring internally and --password-check covers existence, so the only legitimate need for the raw value is handing it to another program. Make --password-get TTY-aware: on an interactive terminal it just confirms the key exists and shows how to pipe it; when stdout is a pipe or file it emits only the raw value (no decoration, no trailing newline) so it can be captured cleanly, e.g. PW=$(sshx --password-get=key) or `... | pbcopy`. The plaintext warning is written to stderr so it never pollutes the value. Update usage text, SKILL.md, READMEs, and the list-passwords hint (now points at --password-check), plus a CHANGELOG Security note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ README.md | 19 ++++++++++--------- README_CN.md | 19 ++++++++++--------- internal/app/password.go | 22 ++++++++++++++++------ internal/app/usage.go | 2 +- skills/sshx/SKILL.md | 2 +- 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d255d3a..9dc3608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- `--password-get` no longer prints the stored secret to an interactive terminal (where it would linger in scrollback). On a TTY it now only confirms the key exists; the raw value is emitted **only** when stdout is piped or redirected (e.g. `PW=$(sshx --password-get=key)` or `sshx --password-get=key | pbcopy`), with no decoration for clean capture + ### Changed - `--help` and the no-argument usage screen now print the build version (`Version: `) diff --git a/README.md b/README.md index d1494ea..46220fc 100644 --- a/README.md +++ b/README.md @@ -398,15 +398,16 @@ sshx --password-list #### Get Password ```bash -# Get stored password (for debugging) -sshx --password-get=master - -# Output example: -# ✓ Password retrieved from system keyring -# Service: sshx -# Key: master -# -# Password: yourpassword +# Read a stored password. On a terminal sshx only confirms the key exists; to +# obtain the value, pipe stdout — it is emitted raw, with no decoration. +PW=$(sshx --password-get=master) # capture into a variable +sshx --password-get=master | pbcopy # copy to clipboard (macOS) + +# Interactive output example (the secret is NOT printed to the terminal): +# ✓ Password exists for key 'master' (service: sshx) +# Not printing the secret to a terminal. To use it, pipe stdout: +# sshx --password-get=master | pbcopy +# sshx --password-get=master | cat ``` #### Delete Password diff --git a/README_CN.md b/README_CN.md index ac15c2a..8bce699 100644 --- a/README_CN.md +++ b/README_CN.md @@ -332,15 +332,16 @@ sshx --password-list #### 获取密码 ```bash -# 获取存储的密码(用于调试) -sshx --password-get=master - -# 输出示例: -# ✓ Password retrieved from system keyring -# Service: sshx -# Key: master -# -# Password: yourpassword +# 读取已存储的密码。在终端中 sshx 只确认该 key 是否存在; +# 如需取出明文,请将 stdout 通过管道传出——此时原样输出,无任何修饰。 +PW=$(sshx --password-get=master) # 捕获到变量 +sshx --password-get=master | pbcopy # 复制到剪贴板(macOS) + +# 终端下的输出示例(不会把密码打印到终端): +# ✓ Password exists for key 'master' (service: sshx) +# Not printing the secret to a terminal. To use it, pipe stdout: +# sshx --password-get=master | pbcopy +# sshx --password-get=master | cat ``` #### 删除密码 diff --git a/internal/app/password.go b/internal/app/password.go index 000cda8..a294e10 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -80,12 +80,22 @@ func getPassword(serviceName, key string) error { return fmt.Errorf("failed to get password: %w", err) } - logger.GetLogger().Success("Password retrieved from system keyring") - logger.GetLogger().Info(" Service: %s", serviceName) - logger.GetLogger().Info(" Key: %s", key) - fmt.Printf("\nPassword: %s\n", password) - logger.GetLogger().Warning("Password printed in plaintext; clear your terminal scrollback if it is sensitive.") + // Never dump a secret onto an interactive terminal, where it would linger in + // scrollback and shoulder-surfing range. sshx already uses the keyring + // internally (it auto-fills sudo over stdin), so the plaintext value is only + // needed when handing it to another program. When stdout is a pipe or file we + // emit just the raw value (no decoration, no trailing newline) so it can be + // captured cleanly, e.g. PW=$(sshx --password-get=key) or `... | pbcopy`. + if term.IsTerminal(int(os.Stdout.Fd())) { + logger.GetLogger().Success("Password exists for key '%s' (service: %s)", key, serviceName) + logger.GetLogger().Info("Not printing the secret to a terminal. To use it, pipe stdout:") + logger.GetLogger().Info(" sshx --password-get=%s | pbcopy # copy to clipboard (macOS)", key) + logger.GetLogger().Info(" sshx --password-get=%s | cat # show on screen if you must", key) + return nil + } + logger.GetLogger().Warning("Emitting the plaintext password for key '%s' on stdout.", key) + fmt.Print(password) return nil } @@ -173,7 +183,7 @@ func listPasswords() error { fmt.Println("Custom password keys you've set (like 'test-password') are stored") fmt.Println("but not listed here due to keyring API limitations.") fmt.Println("\nTo check a custom key:") - fmt.Println(" sshx --password-get=") + fmt.Println(" sshx --password-check=") fmt.Println("\nPlatform-specific commands to list all:") if isMacOS() { fmt.Println(" macOS: security find-generic-password -s sshx") diff --git a/internal/app/usage.go b/internal/app/usage.go index aabbd10..885a049 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -77,7 +77,7 @@ SFTP Options: Password Management (Cross-Platform): --password-set=[:] Set password in system keyring If password omitted, will prompt - --password-get= Get password from keyring + --password-get= Output the password (raw value only when piped; on a terminal just confirms it exists) --password-check= Check if password exists (alias: --password-exists) --password-delete= Delete password from keyring (alias: --password-del) --password-list List common password keys (alias: --password-ls) diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index 2f3f090..d94c0ec 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -162,7 +162,7 @@ Windows Credential Manager) under service name `sshx`. ```bash sshx --password-set=master # prompt (no echo) — preferred sshx --password-set=master:secret # inline (convenient but warned against) -sshx --password-get=master # read back +sshx --password-get=master # confirm exists on a TTY; pipe (e.g. `| pbcopy`) to emit the raw value sshx --password-check=server-A # exists? (alias: --password-exists) sshx --password-list # common keys (alias: --password-ls) sshx --password-delete=server-A # delete (alias: --password-del)