CLI 工具直接跑在使用者的終端機環境中,安全風險大多出在它跟 Shell、作業系統與外部程式的互動上:從使用者在終端機輸入命令、程式輸出 log,到呼叫其他系統命令,每個步驟都直接暴露在作業系統環境中。這篇將依序看這些互動情境,說明常見的安全風險與防護做法。
當 CLI 工具需要使用者提供密碼或 API token 等敏感資訊時(例如執行 mycli login 完成身份驗證),最直覺的命令設計可能是在 subcommand 加上 Flag,讓使用者直接帶入參數:
// 用法:mycli login --password mySecret123
// ps aux 會清楚看到 mySecret123
loginCmd.Flags().StringP("password", "p", "", "Password")
把密碼直接作為命令列參數傳遞,這串敏感資料會在執行過程中被三個地方記錄下來:
~/.bash_history 或 ~/.zsh_history,事後可以直接翻出來。ps aux 都能看到完整的 Process arguments,密碼會直接顯示在螢幕上。auditd 等稽核機制,自動記錄所有執行過的命令內容。要避免密碼在這些位置留下痕跡,安全的做法是在命令執行時改用互動式輸入,並使用 golang.org/x/term 套件在讀取時關閉 terminal echo。
在 Cobra 的 RunE 中,不再從 Flag 讀取參數,而是直接呼叫互動式讀取函式:
var loginCmd = &cobra.Command{
Use: "login",
Short: "Login to the system",
RunE: func(cmd *cobra.Command, args []string) error {
// 在指令執行過程中要求使用者輸入密碼
password, err := readPassword()
if err != nil {
return fmt.Errorf("read password: %w", err)
}
return authenticate(password)
},
}
readPassword 函式的運作機制如下:
import (
"errors"
"fmt"
"os"
"golang.org/x/term"
)
func readPassword() (string, error) {
fd := int(os.Stdin.Fd())
// 1. 確認標準輸入是否為互動式終端機(避免在 CI 或管道環境中無預期卡死)
if !term.IsTerminal(fd) {
return "", errors.New("cannot read password interactively: stdin is not a terminal")
}
// 2. 提示使用者,並呼叫 ReadPassword 關閉 terminal echo(按鍵不會顯示在螢幕上)
fmt.Print("Password: ")
b, err := term.ReadPassword(fd)
fmt.Println() // ReadPassword 讀取完畢後不會自動換行,需手動補上
return string(b), err
}
實際執行時,使用者只會在終端機看到提示,打字時螢幕不會顯示任何字元:
$ mycli login
Password:
# 輸入密碼時螢幕不會印出字元,按 Enter 後完成輸入
這樣執行時,Process 表(ps aux)與 Shell 歷史紀錄中只會留下 mycli login 命令本身,不會包含密碼內容;終端機螢幕也不會回顯輸入字元。
不過這種做法的前提,是 CLI 本身會經手使用者的帳號密碼。之後的 [[在 CLI 實作 OAuth 2.0 授權登入]] 會換一種做法:把登入交給瀏覽器完成,CLI 從頭到尾只拿到授權後的 token,不接觸也不儲存使用者的密碼。
程式可能不小心把帶有密碼的 struct 印進 log,或者在 debug 模式下輸出完整 config:
[2026-08-01] config: {User:evan Password:mySecret123 Token:sk-abc123}
log.Printf("config: %+v", cfg)
// 輸出:config: {User:alice Password:mySecret Token:abc123}
使用 SecretString 型別自動遮蔽:
type SecretString string
func (s SecretString) String() string {
return "[REDACTED]"
}
func (s SecretString) MarshalJSON() ([]byte, error) {
return []byte(`"[REDACTED]"`), nil
}
type Config struct {
User string
Password SecretString
Token SecretString
}
// log.Printf("%+v", cfg) 安全輸出:
// config: {User:alice Password:[REDACTED] Token:[REDACTED]}
SecretString 只改寫了 String() 和 MarshalJSON(),因此只有走這兩個方法的輸出會變成 [REDACTED],例如 %v、%+v 和 json.Marshal。其他路徑照樣會印出原始內容:string(cfg.Password) 轉回一般字串、用 %#v 印出結構,或是靠反射讀欄位的 dump 工具。它擋的是順手寫下的 log,不是有意的取值,所以敏感欄位不得進入 log 這條規則還是要維持。
CLI 工具經常需要呼叫系統中既有的命令(例如 git 或 docker)。假設程式提供一個 mycli clone <repo-url> 命令,內部需要執行 git clone 下載專案。
最常見的漏洞是把使用者輸入的字串直接拼接到 Shell 命令中(例如帶入 sh -c):
// 危險:將使用者輸入拼接到 Shell 字串中
repoURL := "https://github.com/user/repo.git; rm -rf /"
cmd := exec.Command("sh", "-c", "git clone " + repoURL)
err := cmd.Run()
為什麼會發生命令注入?
當程式透過 sh -c 執行時,作業系統會啟動 Shell 直譯器解析這串命令。Shell 會將分號 ;、管道 | 或 && 視為命令分隔符號。在此範例中,Shell 會將字串拆解成兩條獨立命令執行:先跑 git clone,接著直接在電腦上執行 rm -rf /。
安全的做法是不要把整串命令交給 Shell,而是直接指定要執行的程式,並把程式名稱和每個參數分開傳進去。過程中沒有 Shell 參與,也就沒有任何一層會去解析字串裡的 ; 或 |:
func cloneRepository(ctx context.Context, repoURL string) error {
// 安全:直接呼叫 git,將參數作為獨立字串傳遞,並用 -- 終止選項解析
cmd := exec.CommandContext(ctx, "git", "clone", "--", repoURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("clone repository: %w", err)
}
return nil
}
這項安全的防護包含兩個關鍵機制:
exec.CommandContext("git", ...) 會直接透過作業系統(execve)啟動 git 執行檔。由於沒有 Shell 參與解析,即使 repoURL 中含有 ; rm -rf /,作業系統也會將整串內容當成單一字串交給 git,git 只會回報網址無效,不會執行任何額外命令。-- 終止選項解析(避免 Flag 注入):傳入 -- 能告訴 git 後續所有參數都是純資料(如 Repository URL),防止攻擊者傳入以 - 開頭的惡意選項(例如 -oProxyCommand=...)觸發潛在的選項目標注入。以上就是開發安全的 CLI 工具時,需要注意的幾項安全事項:
ps 看光,改成執行時再讓使用者輸入。[REDACTED];不過這只擋得住順手寫下的 log,敏感資料還是不要進 log。sh -c 拼字串,直接指定程式、參數一個一個傳,再加上 --,;、| 就不會被當成命令,- 開頭的輸入也不會被當成選項。