diff --git a/AGENT.md b/AGENT.md index bcab0ae..52baf15 100644 --- a/AGENT.md +++ b/AGENT.md @@ -38,6 +38,9 @@ The core value proposition: password keys so one tool covers a whole fleet. 6. **Execution preview** — `--dry-run` explains the local execution plan without connecting, executing, reading keyring secrets, or mutating state. +7. **Auditability** — non-dry-run invocations write structured JSONL audit + events under `~/.sshx/audit` by default, with secrets and stdout/stderr + excluded. ## 3. Scope & Boundaries (Non-Goals) @@ -77,6 +80,7 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman password.go → keyring-backed password get/set/list + secure input usage.go → PrintUsage() help text (keep in sync with flags) dryrun.go → --dry-run local execution plan preview + audit.go → local structured JSONL audit events + redaction internal/sshclient/ → SSH/SFTP core client.go → SSHClient: dial, auth, exec, SFTP, sudo-over-stdin validate.go → command safety checks + CommandUsesSudo @@ -103,6 +107,9 @@ pkg/logger/ → leveled logger (SSHX_LOG_LEVEL) - **Secrets:** OS keyring under service name `sshx` (macOS Keychain / Linux Secret Service / Windows Credential Manager). - **Trust store:** `~/.ssh/known_hosts` (or `--known-hosts` / `SSH_KNOWN_HOSTS`). +- **Audit trail:** `~/.sshx/audit/sshx-YYYY-MM-DD.jsonl` by default; override + with `--audit-output=` / `SSHX_AUDIT_OUTPUT`, or disable with + `--no-audit` / `SSHX_NO_AUDIT=true`. ## 5. Tech Stack @@ -235,7 +242,6 @@ A living, maintainer-adjustable plan. Items must respect the boundaries in §3. **Long-term / under consideration** -- ⬜ Optional structured audit log of executed commands. - ⬜ Pluggable secret backends behind the existing keyring abstraction. Anything implying a daemon, MCP, tunneling, or a GUI is explicitly **rejected** diff --git a/CHANGELOG.md b/CHANGELOG.md index 5203ada..1988a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Local structured audit events are written by default to + `~/.sshx/audit/sshx-YYYY-MM-DD.jsonl`. Use `--audit-output=` to choose a + directory or `--no-audit` / `SSHX_NO_AUDIT=true` to disable audit writing. + Audit events omit stdout/stderr and never store plaintext passwords or private + key contents. - `--dry-run` prints a local execution plan without connecting, executing, reading keyring secrets, mutating `known_hosts`, or writing local/remote state. Combine with `--json` for agent-readable plan output. diff --git a/README.md b/README.md index 1e849ce..4b44f1f 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ Managing multiple servers means juggling different passwords and repeatedly ente 2. Password management (Keychain / Secret Service / Credential Manager). 3. Host configuration management with per-host SSH keys. 4. Dry-run execution plan preview for humans and agents. -5. Script execution and command security validation. +5. Local structured audit trail with safe default redaction. +6. Script execution and command security validation. ## Installation @@ -266,6 +267,28 @@ sudo-key selection, safety-check result, and whether a real run would connect, execute, read a secret, or mutate state. It does **not** prove the remote command would succeed. +### Local audit trail + +Every non-dry-run invocation writes one JSONL audit event by default: + +```text +~/.sshx/audit/sshx-YYYY-MM-DD.jsonl +``` + +Use `--audit-output=` to place audit events next to a project, runbook, or +incident record: + +```bash +sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx" +``` + +Audit events record metadata and outcomes such as mode/action, host resolution, +sudo/keyring decisions, safety status, auth method, exit code, and error kind. +They do **not** record plaintext passwords, private key contents, or +stdout/stderr. Command text is included for provenance but redacted for common +password/token-style arguments. Use `--no-audit` or `SSHX_NO_AUDIT=true` to +disable audit writing for a single invocation or environment. + ### `--timeout` and `--pty` ```bash @@ -549,6 +572,16 @@ export SUDO_PASSWORD=your_sudo_password ./bin/sshx "uptime" ``` +### Audit Environment Variables + +```bash +# Write audit events to a project-specific directory +export SSHX_AUDIT_OUTPUT=./.sshx-audit + +# Disable audit writing +export SSHX_NO_AUDIT=true +``` + ### SSH Authentication Preferences - `sshx` now prioritizes SSH keys and automatically falls back to password authentication when the server rejects your key (for example when a host only allows passwords). As long as a password is available, the client will transparently retry with a password-only session. diff --git a/README_CN.md b/README_CN.md index fa4787e..1c1bed9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -62,7 +62,8 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ 2. 密码管理(Keychain / Secret Service / Credential Manager)。 3. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。 4. 面向人和 agent 的 dry-run 执行计划预览。 -5. 脚本执行和命令安全验证。 +5. 本地结构化审计日志,并默认做安全脱敏。 +6. 脚本执行和命令安全验证。 ## 安装 @@ -260,6 +261,24 @@ sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx" dry-run 只是本地计划预览。它会说明主机解析、模式/动作、sudo key 选择、安全检查结果, 以及真实执行时是否会连接、执行、读取 secret 或修改状态;它不承诺远程命令一定成功。 +### 本地审计日志 + +每次非 dry-run 调用都会默认写入一条 JSONL 审计事件: + +```text +~/.sshx/audit/sshx-YYYY-MM-DD.jsonl +``` + +可以使用 `--audit-output=<目录>` 把审计事件保存到项目目录、运维记录或事故目录旁边: + +```bash +sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx" +``` + +审计事件记录模式/动作、主机解析、sudo/keyring 决策、安全检查状态、认证方式、退出码和错误类型等元数据。 +它不会记录明文密码、私钥内容或 stdout/stderr。命令文本会作为溯源信息写入,但会对常见 password/token +类参数做尽力脱敏。可以用 `--no-audit` 或 `SSHX_NO_AUDIT=true` 禁用单次调用或当前环境的审计写入。 + ### `--timeout` 与 `--pty` ```bash @@ -467,6 +486,16 @@ export SUDO_PASSWORD=your_sudo_password ./bin/sshx "uptime" ``` +### 审计环境变量 + +```bash +# 将审计事件写到项目目录 +export SSHX_AUDIT_OUTPUT=./.sshx-audit + +# 禁用审计写入 +export SSHX_NO_AUDIT=true +``` + ### SSH 认证偏好设置 - `sshx` 仍然会优先尝试 SSH 密钥认证,但如果服务器拒绝公钥(例如只允许密码登录),并且已经提供了密码,客户端会自动回退到“仅密码”重连,无需手动重试。 diff --git a/internal/app/agentmode_test.go b/internal/app/agentmode_test.go index 0539c0c..7146523 100644 --- a/internal/app/agentmode_test.go +++ b/internal/app/agentmode_test.go @@ -129,6 +129,7 @@ func TestClassifyError(t *testing.T) { // before any network work, so it reports error_kind "blocked" (not "connect") // even though the host is never reachable. func TestRun_BlockedCommandShortCircuits(t *testing.T) { + t.Setenv("HOME", t.TempDir()) old := os.Stdout r, w, err := os.Pipe() if err != nil { diff --git a/internal/app/app.go b/internal/app/app.go index 9e729e6..d6fcd17 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -71,6 +71,12 @@ func Run(args []string) (err error) { // Parse command-line arguments config := ParseArgs(args) + audit := newAuditRecorder(config) + defer func() { + if auditErr := audit.finish(config, err); auditErr != nil { + logger.GetLogger().Warning("failed to write audit event: %v", auditErr) + } + }() if config.DryRun { return emitDryRunPlan(config) @@ -95,11 +101,11 @@ func Run(args []string) (err error) { // Validate flags that only apply to command execution. if config.Mode == "ssh" { if config.Timeout < 0 { - return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config", + return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config", fmt.Errorf("invalid --timeout value (use e.g. 30s, 2m, or 30)")) } if config.JSONOutput && config.UsePTY { - return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config", + return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config", fmt.Errorf("--pty cannot be combined with --json (a PTY merges stderr into stdout)")) } @@ -108,7 +114,7 @@ func Run(args []string) (err error) { // error_kind ("blocked") instead of being masked by a connect error. if config.SafetyCheck && !config.Force { if blockErr := sshclient.ValidateCommand(config.Command); blockErr != nil { - return reportSSHFailure(config, sshclient.AuthMethodUnknown, classifyError(blockErr), blockErr) + return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, classifyError(blockErr), blockErr) } } } @@ -135,16 +141,19 @@ func Run(args []string) (err error) { // Create SSH client client, err := sshclient.NewSSHClient(config) if err != nil { - return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config", + return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config", fmt.Errorf("failed to create SSH client: %w", err)) } defer errutil.HandleCloseError(&err, client) // Connect to remote host (use direct connection for CLI mode, no need for pooling) if err = client.ConnectDirect(); err != nil { - return reportSSHFailure(config, sshclient.AuthMethodUnknown, classifyError(err), + return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, classifyError(err), fmt.Errorf("failed to connect: %w", err)) } + if audit != nil { + audit.event.AuthMethod = string(client.AuthMethodUsed()) + } // Handle SFTP mode if config.Mode == "sftp" { @@ -155,16 +164,17 @@ func Run(args []string) (err error) { } // Handle SSH command execution - return runCommand(client, config) + return runCommand(client, config, audit) } // runCommand runs the configured command and translates the result into either // streamed human output or a single JSON object, then returns an error whose // type tells the entry point which exit code to use. -func runCommand(client *sshclient.SSHClient, config *sshclient.Config) error { +func runCommand(client *sshclient.SSHClient, config *sshclient.Config, audit *auditRecorder) error { start := time.Now() res, execErr := client.RunCommand(config.JSONOutput) dur := time.Since(start) + audit.recordCommandResult(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr) if config.JSONOutput { emitCommandJSON(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr) @@ -189,7 +199,8 @@ func runCommand(client *sshclient.SSHClient, config *sshclient.Config) error { // reportSSHFailure emits a JSON error object in --json command mode (and returns // ErrReported so the caller exits silently), or returns the error unchanged for // the normal streamed path. -func reportSSHFailure(config *sshclient.Config, authMethod sshclient.AuthMethod, kind string, err error) error { +func reportSSHFailure(config *sshclient.Config, audit *auditRecorder, authMethod sshclient.AuthMethod, kind string, err error) error { + audit.recordFailure(config, authMethod, kind, err) if config.JSONOutput && config.Mode == "ssh" { emitCommandJSON(config, authMethod, sshclient.ExecResult{ExitCode: -1}, 0, kind, err) return ErrReported diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e546ebb..831d5c8 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -115,6 +115,7 @@ func TestRun_ArgumentParsing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // Suppress output oldStdout := os.Stdout oldStderr := os.Stderr diff --git a/internal/app/audit.go b/internal/app/audit.go new file mode 100644 index 0000000..df70cb3 --- /dev/null +++ b/internal/app/audit.go @@ -0,0 +1,395 @@ +package app + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/talkincode/sshx/internal/sshclient" +) + +const ( + auditSchemaVersion = "sshx.audit.v1" + auditDirName = "audit" +) + +type auditStatus struct { + Status string `json:"status"` + ErrorKind string `json:"error_kind,omitempty"` + Message string `json:"message,omitempty"` +} + +type auditRedaction struct { + SecretsRedacted bool `json:"secrets_redacted"` + StdoutOmitted bool `json:"stdout_omitted"` + StderrOmitted bool `json:"stderr_omitted"` +} + +type auditEvent struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + Timestamp string `json:"timestamp"` + Version string `json:"version,omitempty"` + Actor string `json:"actor,omitempty"` + OS string `json:"os"` + Arch string `json:"arch"` + + Mode string `json:"mode"` + Action string `json:"action,omitempty"` + + HostInput string `json:"host_input,omitempty"` + HostResolved string `json:"host_resolved,omitempty"` + Port string `json:"port,omitempty"` + User string `json:"user,omitempty"` + HostName string `json:"host_name,omitempty"` + HostType string `json:"host_type,omitempty"` + HostDescSet bool `json:"host_description_set"` + HostResolvedBy string `json:"host_resolved_by,omitempty"` + + Command string `json:"command,omitempty"` + SftpAction string `json:"sftp_action,omitempty"` + LocalPath string `json:"local_path,omitempty"` + RemotePath string `json:"remote_path,omitempty"` + + UseKeyAuth bool `json:"use_key_auth"` + KeyPath string `json:"key_path,omitempty"` + PasswordProvided bool `json:"password_provided"` + PasswordValueProvided bool `json:"password_value_provided"` + PasswordKey string `json:"password_key,omitempty"` + UsesSudo bool `json:"uses_sudo"` + SudoKey string `json:"sudo_key,omitempty"` + + Timeout string `json:"timeout,omitempty"` + JSONOutput bool `json:"json_output"` + UsePTY bool `json:"pty"` + SafetyCheckEnabled bool `json:"safety_check_enabled"` + Force bool `json:"force"` + AcceptUnknownHost bool `json:"accept_unknown_host"` + AllowInsecureHostKey bool `json:"allow_insecure_host_key"` + KnownHostsPath string `json:"known_hosts_path,omitempty"` + + WouldReadSecret bool `json:"would_read_secret"` + WouldWriteLocalState bool `json:"would_write_local_state"` + WouldMutateRemote bool `json:"would_mutate_remote"` + MayMutateKnownHosts bool `json:"may_mutate_known_hosts"` + + AuthMethod string `json:"auth_method,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + DurationMs int64 `json:"duration_ms"` + Outcome auditStatus `json:"outcome"` + Redaction auditRedaction `json:"redaction"` +} + +type auditRecorder struct { + started time.Time + event auditEvent + completed bool +} + +var ( + sensitiveAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=([^\s&;]+)`) + sensitiveFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))([^\s]+)`) +) + +func newAuditRecorder(config *sshclient.Config) *auditRecorder { + if config == nil || !config.AuditEnabled || config.DryRun { + return nil + } + + started := time.Now() + return &auditRecorder{ + started: started, + event: auditEvent{ + SchemaVersion: auditSchemaVersion, + EventID: newAuditEventID(), + Timestamp: started.UTC().Format(time.RFC3339Nano), + Version: Version, + Actor: currentActor(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + HostInput: config.Host, + Outcome: auditStatus{Status: "started"}, + Redaction: auditRedaction{ + SecretsRedacted: true, + StdoutOmitted: true, + StderrOmitted: true, + }, + }, + } +} + +func (r *auditRecorder) recordCommandResult(config *sshclient.Config, authMethod sshclient.AuthMethod, res sshclient.ExecResult, dur time.Duration, errKind string, execErr error) { + if r == nil { + return + } + r.refresh(config) + r.event.AuthMethod = string(authMethod) + r.event.DurationMs = dur.Milliseconds() + r.event.ExitCode = intPtr(res.ExitCode) + if execErr != nil { + r.event.Outcome = auditStatus{ + Status: "failure", + ErrorKind: errKind, + Message: redactSensitiveText(execErr.Error()), + } + r.completed = true + return + } + if res.ExitCode != 0 { + r.event.Outcome = auditStatus{ + Status: "failure", + ErrorKind: "remote_exit", + Message: fmt.Sprintf("command exited with status %d", res.ExitCode), + } + r.completed = true + return + } + r.event.Outcome = auditStatus{Status: "success"} + r.completed = true +} + +func (r *auditRecorder) recordFailure(config *sshclient.Config, authMethod sshclient.AuthMethod, kind string, err error) { + if r == nil { + return + } + r.refresh(config) + switch kind { + case "blocked", "config": + r.event.WouldReadSecret = false + r.event.WouldMutateRemote = false + r.event.MayMutateKnownHosts = false + case "connect", "auth", "host_key": + r.event.WouldMutateRemote = false + } + r.event.AuthMethod = string(authMethod) + r.event.ExitCode = intPtr(-1) + r.event.Outcome = auditStatus{ + Status: "failure", + ErrorKind: kind, + Message: redactError(err), + } + r.completed = true +} + +func (r *auditRecorder) finish(config *sshclient.Config, err error) error { + if r == nil { + return nil + } + if !r.completed { + r.refresh(config) + r.event.DurationMs = time.Since(r.started).Milliseconds() + var exitErr *ExitError + switch { + case err == nil: + r.event.Outcome = auditStatus{Status: "success"} + case errors.As(err, &exitErr): + r.event.ExitCode = intPtr(exitErr.Code) + r.event.Outcome = auditStatus{ + Status: "failure", + ErrorKind: "remote_exit", + Message: exitErr.Error(), + } + default: + r.event.Outcome = auditStatus{ + Status: "failure", + ErrorKind: classifyError(err), + Message: redactError(err), + } + } + r.completed = true + } + return writeAuditEvent(config, r.event, r.started) +} + +func (r *auditRecorder) refresh(config *sshclient.Config) { + if r == nil || config == nil { + return + } + r.event.Mode = config.Mode + r.event.Action = auditAction(config) + r.event.HostResolved = config.Host + r.event.Port = config.Port + r.event.User = config.User + r.event.HostName = config.HostName + r.event.HostType = config.HostType + r.event.HostDescSet = config.HostDescription != "" + if r.event.HostInput != "" && r.event.HostResolved != "" && r.event.HostResolved != r.event.HostInput { + r.event.HostResolvedBy = "settings" + } + r.event.Command = redactSensitiveText(config.Command) + r.event.SftpAction = config.SftpAction + r.event.LocalPath = config.LocalPath + r.event.RemotePath = config.RemotePath + r.event.UseKeyAuth = config.UseKeyAuth + r.event.KeyPath = config.KeyPath + r.event.PasswordProvided = config.Password != "" + r.event.PasswordValueProvided = config.PasswordValue != "" + r.event.PasswordKey = config.PasswordKey + r.event.UsesSudo = sshclient.CommandUsesSudo(config.Command) + r.event.SudoKey = config.SudoKey + if config.Timeout > 0 { + r.event.Timeout = config.Timeout.String() + } + r.event.JSONOutput = config.JSONOutput + r.event.UsePTY = config.UsePTY + r.event.SafetyCheckEnabled = config.SafetyCheck + r.event.Force = config.Force + r.event.AcceptUnknownHost = config.AcceptUnknownHost + r.event.AllowInsecureHostKey = config.AllowInsecureHostKey + r.event.KnownHostsPath = config.KnownHostsPath + r.event.WouldReadSecret = auditWouldReadSecret(config) + r.event.WouldWriteLocalState = auditWouldWriteLocalState(config) + r.event.WouldMutateRemote = auditWouldMutateRemote(config) + r.event.MayMutateKnownHosts = modeUsesSSHConnection(config) && config.AcceptUnknownHost + if r.event.DurationMs == 0 { + r.event.DurationMs = time.Since(r.started).Milliseconds() + } +} + +func writeAuditEvent(config *sshclient.Config, event auditEvent, now time.Time) error { + dir, err := auditOutputDir(config) + if err != nil { + return err + } + if mkdirErr := os.MkdirAll(dir, 0o700); mkdirErr != nil { + return fmt.Errorf("failed to create audit directory %s: %w", dir, mkdirErr) + } + path := filepath.Join(dir, fmt.Sprintf("sshx-%s.jsonl", now.Format("2006-01-02"))) + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) // #nosec G304 -- audit path is user-configurable by design. + if err != nil { + return fmt.Errorf("failed to open audit log %s: %w", path, err) + } + defer func() { _ = file.Close() }() //nolint:errcheck // best effort after append + + enc := json.NewEncoder(file) + enc.SetEscapeHTML(false) + if err := enc.Encode(event); err != nil { + return fmt.Errorf("failed to write audit event: %w", err) + } + return nil +} + +func auditOutputDir(config *sshclient.Config) (string, error) { + if config != nil && config.AuditOutput != "" { + return expandHome(config.AuditOutput) + } + settingsDir, err := GetSettingsDir() + if err != nil { + return "", err + } + return filepath.Join(settingsDir, auditDirName), nil +} + +func expandHome(path string) (string, error) { + if path == "" || path[0] != '~' { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + if path == "~" { + return home, nil + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, path[2:]), nil + } + return path, nil +} + +func auditAction(config *sshclient.Config) string { + switch config.Mode { + case "ssh": + return "command" + case "sftp": + return config.SftpAction + case "password": + return config.PasswordAction + case "host": + return config.HostAction + default: + return "" + } +} + +func auditWouldReadSecret(config *sshclient.Config) bool { + switch config.Mode { + case "ssh": + return sshclient.CommandUsesSudo(config.Command) && config.SudoKey != "" + case "password": + return config.PasswordAction == "get" || config.PasswordAction == "check" || config.PasswordAction == "delete" || config.PasswordAction == "list" + case "host": + return config.HostAction == "test" || config.HostAction == "test-all" + default: + return false + } +} + +func auditWouldWriteLocalState(config *sshclient.Config) bool { + switch config.Mode { + case "password": + return config.PasswordAction == "set" || config.PasswordAction == "delete" + case "host": + return config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove" + default: + return false + } +} + +func auditWouldMutateRemote(config *sshclient.Config) bool { + switch config.Mode { + case "ssh": + return config.Command != "" + case "sftp": + return config.SftpAction == "upload" || config.SftpAction == "mkdir" || config.SftpAction == "remove" + case "host": + return config.HostAction == "test" || config.HostAction == "test-all" + default: + return false + } +} + +func redactSensitiveText(value string) string { + if value == "" { + return "" + } + value = sensitiveAssignmentRE.ReplaceAllString(value, "$1=") + value = sensitiveFlagRE.ReplaceAllString(value, "$1") + return value +} + +func redactError(err error) string { + if err == nil { + return "" + } + return redactSensitiveText(err.Error()) +} + +func newAuditEventID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err == nil { + return hex.EncodeToString(b[:]) + } + return fmt.Sprintf("%d", time.Now().UnixNano()) +} + +func currentActor() string { + for _, key := range []string{"USER", "USERNAME", "LOGNAME"} { + if value := os.Getenv(key); value != "" { + return value + } + } + return "" +} + +func intPtr(value int) *int { + return &value +} diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go new file mode 100644 index 0000000..ef2f181 --- /dev/null +++ b/internal/app/audit_test.go @@ -0,0 +1,191 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/talkincode/sshx/internal/sshclient" +) + +func TestRun_BlockedCommandWritesRedactedAuditEvent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + auditDir := t.TempDir() + command := "sudo rm -rf / password=orange --token purple" //nolint:gosec // test verifies redaction of credential-like arguments. + + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdout = w + + runErr := Run([]string{"sshx", "-h=192.0.2.1", "--audit-output=" + auditDir, "--json", command}) + + if closeErr := w.Close(); closeErr != nil { + t.Logf("failed to close pipe writer: %v", closeErr) + } + os.Stdout = old + if _, copyErr := io.Copy(io.Discard, r); copyErr != nil { + t.Logf("failed to drain stdout: %v", copyErr) + } + + if !errors.Is(runErr, ErrReported) { + t.Fatalf("expected ErrReported, got %v", runErr) + } + + event := readSingleAuditEvent(t, auditDir) + if event["schema_version"] != auditSchemaVersion { + t.Fatalf("expected schema %q, got %v", auditSchemaVersion, event["schema_version"]) + } + if event["mode"] != "ssh" { + t.Errorf("expected ssh mode, got %v", event["mode"]) + } + if event["action"] != "command" { + t.Errorf("expected command action, got %v", event["action"]) + } + if event["host_input"] != "192.0.2.1" { + t.Errorf("expected host input, got %v", event["host_input"]) + } + if event["uses_sudo"] != true { + t.Errorf("expected uses_sudo=true, got %v", event["uses_sudo"]) + } + if event["would_read_secret"] != false { + t.Errorf("blocked command must not audit a secret read, got %v", event["would_read_secret"]) + } + if event["would_mutate_remote"] != false { + t.Errorf("blocked command must not audit remote mutation, got %v", event["would_mutate_remote"]) + } + + auditedCommand, ok := event["command"].(string) + if !ok { + t.Fatalf("expected command string, got %T", event["command"]) + } + if strings.Contains(auditedCommand, "orange") || strings.Contains(auditedCommand, "purple") { + t.Fatalf("audit command was not redacted: %q", auditedCommand) + } + if !strings.Contains(auditedCommand, "password=") || !strings.Contains(auditedCommand, "--token ") { + t.Errorf("audit command did not include expected redaction markers: %q", auditedCommand) + } + + outcome, ok := event["outcome"].(map[string]any) + if !ok { + t.Fatalf("expected outcome object, got %T", event["outcome"]) + } + if outcome["status"] != "failure" { + t.Errorf("expected failure outcome, got %v", outcome["status"]) + } + if outcome["error_kind"] != "blocked" { + t.Errorf("expected blocked error kind, got %v", outcome["error_kind"]) + } + message, ok := outcome["message"].(string) + if !ok { + t.Fatalf("expected outcome message string, got %T", outcome["message"]) + } + if strings.Contains(message, "orange") || strings.Contains(message, "purple") { + t.Fatalf("audit error message was not redacted: %q", message) + } + + redaction, ok := event["redaction"].(map[string]any) + if !ok { + t.Fatalf("expected redaction object, got %T", event["redaction"]) + } + if redaction["secrets_redacted"] != true || redaction["stdout_omitted"] != true || redaction["stderr_omitted"] != true { + t.Errorf("unexpected redaction metadata: %v", redaction) + } + if _, exists := event["stdout"]; exists { + t.Error("audit event must not include stdout") + } + if _, exists := event["stderr"]; exists { + t.Error("audit event must not include stderr") + } +} + +func TestRun_DryRunDoesNotWriteAuditEvent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + auditDir := filepath.Join(t.TempDir(), "audit") + result := runDryRunJSON(t, []string{"sshx", "-h=192.0.2.1", "--audit-output=" + auditDir, "--dry-run", "--json", "uptime"}) + + if result["dry_run"] != true { + t.Fatalf("expected dry_run=true, got %v", result["dry_run"]) + } + if _, err := os.Stat(auditDir); !os.IsNotExist(err) { + t.Fatalf("dry-run should not create audit directory, stat err=%v", err) + } +} + +func TestWriteAuditEventUsesJSONLWithPrivatePermissions(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + config := &sshclient.Config{AuditEnabled: true} + event := auditEvent{ + SchemaVersion: auditSchemaVersion, + EventID: "test-event", + Timestamp: "2026-06-20T00:00:00Z", + Mode: "ssh", + Action: "command", + Outcome: auditStatus{Status: "success"}, + Redaction: auditRedaction{SecretsRedacted: true, StdoutOmitted: true, StderrOmitted: true}, + } + + if err := writeAuditEvent(config, event, mustParseDate(t, "2026-06-20")); err != nil { + t.Fatalf("writeAuditEvent() error = %v", err) + } + + auditPath := filepath.Join(home, SettingsDir, auditDirName, "sshx-2026-06-20.jsonl") + info, err := os.Stat(auditPath) + if err != nil { + t.Fatalf("expected audit file at %s: %v", auditPath, err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("expected audit file mode 0600, got %v", info.Mode().Perm()) + } + + data, err := os.ReadFile(auditPath) //nolint:gosec // test reads a controlled temp file. + if err != nil { + t.Fatalf("failed to read audit file: %v", err) + } + if lines := bytes.Count(data, []byte("\n")); lines != 1 { + t.Fatalf("expected one JSONL line, got %d in %q", lines, string(data)) + } +} + +func readSingleAuditEvent(t *testing.T, auditDir string) map[string]any { + t.Helper() + + entries, err := os.ReadDir(auditDir) + if err != nil { + t.Fatalf("failed to read audit directory: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected one audit file, got %d", len(entries)) + } + data, err := os.ReadFile(filepath.Join(auditDir, entries[0].Name())) //nolint:gosec // test reads a controlled temp file. + if err != nil { + t.Fatalf("failed to read audit file: %v", err) + } + lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) + if len(lines) != 1 { + t.Fatalf("expected one audit event, got %d", len(lines)) + } + var event map[string]any + if err := json.Unmarshal(lines[0], &event); err != nil { + t.Fatalf("failed to decode audit event %q: %v", string(lines[0]), err) + } + return event +} + +func mustParseDate(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + t.Fatalf("failed to parse date: %v", err) + } + return parsed +} diff --git a/internal/app/config.go b/internal/app/config.go index 324461e..3f14bb4 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -35,10 +35,11 @@ func parseTimeout(value string) (time.Duration, error) { // ParseArgs parses command-line arguments and returns a Config. func ParseArgs(args []string) *sshclient.Config { config := &sshclient.Config{ - Mode: "ssh", - SafetyCheck: true, - Force: false, - UseKeyAuth: true, + Mode: "ssh", + SafetyCheck: true, + Force: false, + UseKeyAuth: true, + AuditEnabled: true, } if password := os.Getenv("SSH_PASSWORD"); password != "" { @@ -54,6 +55,12 @@ func ParseArgs(args []string) *sshclient.Config { if knownHosts := os.Getenv("SSH_KNOWN_HOSTS"); knownHosts != "" { config.KnownHostsPath = knownHosts } + if auditOutput := os.Getenv("SSHX_AUDIT_OUTPUT"); auditOutput != "" { + config.AuditOutput = auditOutput + } + if noAudit := os.Getenv("SSHX_NO_AUDIT"); strings.EqualFold(noAudit, "true") || noAudit == "1" { + config.AuditEnabled = false + } if acceptUnknown := os.Getenv("SSH_ACCEPT_UNKNOWN_HOST"); strings.EqualFold(acceptUnknown, "true") || acceptUnknown == "1" { config.AcceptUnknownHost = true } @@ -114,6 +121,10 @@ func ParseArgs(args []string) *sshclient.Config { config.SafetyCheck = false case arg == "--dry-run": config.DryRun = true + case strings.HasPrefix(arg, "--audit-output="): + config.AuditOutput = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-audit": + config.AuditEnabled = false case arg == "--json": config.JSONOutput = true case arg == "--pty": diff --git a/internal/app/config_test.go b/internal/app/config_test.go index e914fc0..641f6b8 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -148,6 +148,21 @@ func TestParseArgs_DryRun(t *testing.T) { } } +func TestParseArgs_AuditOptions(t *testing.T) { + config := ParseArgs([]string{"sshx", "-h=host", "--audit-output=/tmp/sshx-audit", "uptime"}) + if !config.AuditEnabled { + t.Error("Expected audit to be enabled by default") + } + if config.AuditOutput != "/tmp/sshx-audit" { + t.Errorf("Expected audit output path, got %q", config.AuditOutput) + } + + config = ParseArgs([]string{"sshx", "-h=host", "--no-audit", "uptime"}) + if config.AuditEnabled { + t.Error("Expected audit to be disabled by --no-audit") + } +} + func TestParseArgs_SFTPUpload(t *testing.T) { args := []string{"sshx", "-h=host", "--upload=local.txt", "--to=/remote/path.txt"} config := ParseArgs(args) diff --git a/internal/app/usage.go b/internal/app/usage.go index 46479ac..dbcd265 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -33,6 +33,8 @@ SSH Options: -pk, --password-key=KEY Sudo password keyring key name (default: master) Used only when the remote command starts with sudo --dry-run Print the local execution plan without side effects + --audit-output=DIR Write audit JSONL files to DIR (default: ~/.sshx/audit) + --no-audit Disable local audit event writing for this invocation --timeout=DURATION Command execution timeout (e.g. 30s, 2m, or 30 = seconds) --json Emit a single structured JSON result on stdout --pty Request a PTY (merges stderr into stdout; off by default) @@ -82,6 +84,17 @@ Dry-run Plan Preview: Dry-run is a local plan preview only. It does not prove the remote command would succeed. +Audit Trail: + sshx writes one structured JSONL audit event per non-dry-run invocation to + ~/.sshx/audit/sshx-YYYY-MM-DD.jsonl by default. Use --audit-output= to + save audit events next to a project or incident record. + + Audit events record metadata and outcomes such as mode/action, host + resolution, sudo/keyring decisions, safety status, auth method, exit code, and + error kind. They do not record plaintext passwords, private key contents, or + stdout/stderr. Command text is best-effort redacted for password/token-style + arguments. + Safety Options: -f, --force Force execution, bypass safety checks (use with caution!) --no-safety-check Disable safety checks completely (not recommended) @@ -141,6 +154,8 @@ Environment Variables (.env): SSH_NO_SAFETY_CHECK Disable safety checks (true/false) SSH_FORCE Force execution mode (true/false) SSH_TIMEOUT Command execution timeout (e.g. 30s, 2m, or 30 = seconds) + SSHX_AUDIT_OUTPUT Audit output directory (default: ~/.sshx/audit) + SSHX_NO_AUDIT Disable audit writing (true/false) SSH Examples: # Execute simple command (default user: master) @@ -162,6 +177,9 @@ SSH Examples: # Preview the execution plan without connecting or reading secrets sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx" + # Save audit events for this project + sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx" + # Bound a command with a timeout (kills it after 30s) sshx -h=192.168.1.100 --timeout=30s "apt-get update" @@ -268,6 +286,7 @@ Note: - SSH key authentication is tried first, then password authentication - Sudo password is auto-filled only when the remote command starts with sudo - Dry-run never connects, executes, reads keyring secrets, or writes state + - Audit events are JSONL files under ~/.sshx/audit by default - SFTP operations use the same SSH connection - Password manager works across macOS/Linux/Windows - Default user: master, Default sudo key: master diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go index 7ddb029..07a4bb4 100644 --- a/internal/app/usage_test.go +++ b/internal/app/usage_test.go @@ -40,6 +40,7 @@ func TestPrintUsage(t *testing.T) { "SSH Options:", "Sudo Auto-fill:", "Dry-run Plan Preview:", + "Audit Trail:", "Safety Options:", "SFTP Options:", "Password Management", @@ -63,6 +64,7 @@ func TestPrintUsage(t *testing.T) { "--password-set=", "--password-get=", "--dry-run", + "--audit-output", "--force", "--no-safety-check", } @@ -89,6 +91,7 @@ func TestPrintUsage(t *testing.T) { "remote command starts with sudo", "Non-leading sudo is not auto-filled", "Dry-run never connects", + "Audit events are JSONL", } for _, keyword := range safetyKeywords { @@ -165,6 +168,7 @@ func TestPrintUsage_Examples(t *testing.T) { `sshx --password-set=master`, `--upload=local.txt --to=/tmp/remote.txt`, `--download=/var/log/app.log`, + `--audit-output=./.sshx-audit`, } for _, example := range examples { diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 9defe0b..55005ab 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -64,6 +64,10 @@ type Config struct { // DryRun emits a local execution plan without connecting, executing, reading // keyring secrets, or mutating local/remote state. DryRun bool + // AuditEnabled controls whether sshx writes a local structured audit event. + AuditEnabled bool + // AuditOutput overrides the directory where audit JSONL files are written. + AuditOutput string SafetyCheck bool Force bool diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index 380d0a8..5eef230 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -56,6 +56,25 @@ Dry-run reports host resolution, mode/action, sudo key selection, safety-check status, and whether a real run would connect, execute, read a secret, or mutate state. It does not simulate remote command success. +## Audit trail + +Every non-dry-run invocation writes one JSONL audit event by default: +`~/.sshx/audit/sshx-YYYY-MM-DD.jsonl`. + +Use `--audit-output=` when the audit record should live with a project or +incident folder: + +```bash +sshx -h=prod-web --audit-output=./.sshx-audit --json "systemctl reload nginx" +``` + +Audit events record metadata and outcomes such as mode/action, host resolution, +sudo/keyring decisions, safety status, auth method, exit code, and error kind. +They do not record plaintext passwords, private key contents, or stdout/stderr. +Command text is included but best-effort redacted for password/token-style +arguments. Use `--no-audit` only when the user explicitly wants no local audit +event for that invocation. + ## Exit codes (and how to read failures) | Exit code | Meaning |