Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.0] - 2026-08-21

### Added

- `sshx sql --sudo` runs the remote `sqlite3`/`psql` client via `sudo -S` so
service-owned database files (typical SQLite under `/var/...`) are reachable
when the SSH user cannot open them. The sudo password is delivered on stdin
ahead of SQL and never appears in argv. Dry-run, JSON (`sudo`), audit
`uses_sudo`, and MCP `sshx_sql` expose the flag.

### Fixed

- SQLite reads no longer pass `sqlite3 -readonly`, a flag missing from older
distro clients (sqlite 3.22 on Ubuntu 18.04 and similar). Reads still open
`file:<path>?mode=ro`, which those clients honor and which still rejects writes.
- `sshx sql --json` failures now include remote `stdout`/`stderr` and append the
client error line to `error`, so agents can see why sqlite3/psql exited.

## [0.7.0] - 2026-08-16

### Added
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ We take security seriously. The following versions of SSHX are currently support

| Version | Supported |
| ------- | ------------------ |
| 0.8.x | :white_check_mark: |
| 0.7.x | :white_check_mark: |
| 0.6.x | :white_check_mark: |
| < 0.6.0 | :x: |
| < 0.7.0 | :x: |

Security updates are provided for the latest minor release and the previous
minor release (N-1). Older lines do not receive patches; please upgrade.
Expand Down
5 changes: 4 additions & 1 deletion internal/app/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ func (r *auditRecorder) refresh(config *sshclient.Config) {
if config.Mode == "apply" {
r.event.UsesSudo = config.ApplyUseSudo
}
if config.Mode == "sql" {
r.event.UsesSudo = config.SQLUseSudo
}
if config.Mode == "inspect" {
if resolved, resolveErr := pluginpkg.Resolve(config.InspectCapability); resolveErr == nil {
if useSudo, _, privilegeErr := inspectionPrivilege(config, resolved.Manifest); privilegeErr == nil {
Expand Down Expand Up @@ -548,7 +551,7 @@ func auditWouldReadSecret(config *sshclient.Config) bool {
}
return config.InspectUseSudo && config.SudoKey != ""
case "sql":
return config.SQLPasswordKey != "" || config.SQLCredFrom != ""
return config.SQLPasswordKey != "" || config.SQLCredFrom != "" || (config.SQLUseSudo && config.SudoKey != "")
case "apply":
return config.ApplyUseSudo && config.SudoKey != ""
default:
Expand Down
2 changes: 2 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,8 @@ func parseSQLArgs(config *sshclient.Config, args []string) {
}
case arg == "--cred-refresh":
config.SQLCredRefresh = true
case arg == "--sudo":
config.SQLUseSudo = true
case arg == "--force", arg == "-f":
config.Force = true
case arg == "--dry-run":
Expand Down
12 changes: 11 additions & 1 deletion internal/app/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ type sqlDryRunPlan struct {
BackupReason string `json:"backup_reason,omitempty"`
ExplainCommand string `json:"explain_command,omitempty"`
ExecuteCommand string `json:"execute_command,omitempty"`
UseSudo bool `json:"use_sudo,omitempty"`
}

func emitDryRunPlan(config *sshclient.Config) error {
Expand Down Expand Up @@ -358,6 +359,10 @@ func fillDryRunSudo(config *sshclient.Config, plan *dryRunPlan) {
plan.UsesSudo = config.ApplyUseSudo
plan.SudoKey = config.SudoKey
}
if config.Mode == "sql" {
plan.UsesSudo = config.SQLUseSudo
plan.SudoKey = config.SudoKey
}
if config.Mode == "host" && config.HostAction == "test" {
return
}
Expand Down Expand Up @@ -527,7 +532,7 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) {
case "sql":
plan.WouldConnect = canProceed
plan.WouldExecute = canProceed && !config.SQLExplainOnly
plan.WouldReadSecret = canProceed && (config.SQLPasswordKey != "" || config.SQLCredFrom != "")
plan.WouldReadSecret = canProceed && (config.SQLPasswordKey != "" || config.SQLCredFrom != "" || (config.SQLUseSudo && config.SudoKey != ""))
plan.WouldWriteLocalState = canProceed && config.SQLCredFrom != "" && config.SQLCredCacheTTL > 0
mutates := plan.SQL != nil && plan.SQL.Class != "" && plan.SQL.Class != string(sqlsafe.ClassRead)
plan.WouldMutateRemote = plan.WouldExecute && mutates
Expand Down Expand Up @@ -561,6 +566,7 @@ func fillDryRunSQL(config *sshclient.Config, plan *dryRunPlan) {
Statement: sqlsafe.RedactForAudit(config.SQLStatement),
StatementHash: sqlStatementDigest(config.SQLStatement),
Docker: config.SQLDockerContainer,
UseSudo: config.SQLUseSudo,
}
if config.SQLCredFrom != "" {
sqlPlan.CredSource = config.SQLCredFrom
Expand Down Expand Up @@ -646,6 +652,10 @@ func fillDryRunSQL(config *sshclient.Config, plan *dryRunPlan) {
sqlPlan.ExecuteCommand = conn.ExecuteCommand(cls.Statement).Command
}
}
if config.SQLUseSudo {
sqlPlan.ExplainCommand = sqlsafe.WrapSudoStdin(sqlPlan.ExplainCommand)
sqlPlan.ExecuteCommand = sqlsafe.WrapSudoStdin(sqlPlan.ExecuteCommand)
}
}

func fillDryRunApply(config *sshclient.Config, plan *dryRunPlan) {
Expand Down
4 changes: 4 additions & 0 deletions internal/app/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type mcpSQLInput struct {
AllowFullTable bool `json:"allow_full_table,omitempty" jsonschema:"Required for UPDATE/DELETE without a WHERE clause."`
NoBackup bool `json:"no_backup,omitempty" jsonschema:"Skip the pre-change backup; requires force."`
BackupDir string `json:"backup_dir,omitempty" jsonschema:"Remote backup directory (default ~/.sshx/sql-backups)."`
Sudo bool `json:"sudo,omitempty" jsonschema:"Run the remote database client via sudo -S when the SSH user cannot open the database file."`
Force bool `json:"force,omitempty" jsonschema:"Confirms DDL; destructive DDL also requires no_backup."`
DryRun bool `json:"dry_run,omitempty" jsonschema:"Preview the guarded SQL plan without connecting."`
TimeoutSecs int `json:"timeout_seconds,omitempty" jsonschema:"Remote execution timeout in seconds."`
Expand Down Expand Up @@ -339,6 +340,9 @@ func buildSQLArgs(in mcpSQLInput) ([]string, error) {
if in.BackupDir != "" {
args = append(args, "--backup-dir="+in.BackupDir)
}
if in.Sudo {
args = append(args, "--sudo")
}
if in.Force {
args = append(args, "--force")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/app/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func TestBuildSQLArgs(t *testing.T) {
DBPasswordKey: "app-db",
Explain: true,
RowThreshold: 500,
Sudo: true,
DryRun: true,
})
if err != nil {
Expand All @@ -82,7 +83,7 @@ func TestBuildSQLArgs(t *testing.T) {
"sql", "--json", "-h=db-1",
"--engine=postgres", "--db=app", "--db-user=app",
"--db-password-key=app-db", "--explain", "--row-threshold=500",
"--dry-run",
"--sudo", "--dry-run",
"--", "SELECT count(*) FROM users",
}
assertArgs(t, args, want)
Expand Down
41 changes: 38 additions & 3 deletions internal/app/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type sqlJSONResult struct {
AuthMethod string `json:"auth_method,omitempty"`
CredSource string `json:"cred_source,omitempty"`
CredCache string `json:"cred_cache,omitempty"`
Sudo bool `json:"sudo,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
Error string `json:"error,omitempty"`
}
Expand Down Expand Up @@ -115,6 +116,13 @@ func HandleSQL(config *sshclient.Config, audit *auditRecorder) (err error) {
logger.GetLogger().Info("Note: Could not find host '%s' in settings, using as hostname directly", config.Host)
}
}
if config.SQLUseSudo {
password, pwdErr := sshclient.GetSudoPassword(config.SudoKey)
if pwdErr != nil {
return run.fail("secret", fmt.Errorf("resolve sudo password key %q: %w", config.SudoKey, pwdErr))
}
config.SudoPassword = password
}
if config.SQLPasswordKey != "" && sqlsafe.NormalizeEngine(config.SQLEngine) != sqlsafe.EngineSQLite {
password, pwdErr := sshclient.GetSudoPassword(config.SQLPasswordKey)
if pwdErr != nil {
Expand Down Expand Up @@ -274,13 +282,23 @@ func validateSQLiteConfig(config *sshclient.Config) error {
}

// runRemote executes one assembled remote command, prepending the database
// password line to stdin when password delivery is enabled.
// password line to stdin when password delivery is enabled. --sudo wraps the
// whole command in sudo -S and prepends the sudo password so it never enters
// argv; sudo consumes the first line and the client sees the rest.
func (r *sqlRun) runRemote(rc sqlsafe.RemoteCommand) (sshclient.ExecResult, error) {
command := rc.Command
stdin := rc.Stdin
if r.exec != nil && r.exec.NeedsPasswordLine() {
stdin = r.password + "\n" + stdin
}
return r.client.RunCommandWithInput(rc.Command, []byte(stdin))
if r.config.SQLUseSudo {
if r.config.SudoPassword == "" {
return sshclient.ExecResult{ExitCode: -1}, fmt.Errorf("sql --sudo requires a resolved sudo password")
}
command = sqlsafe.WrapSudoStdin(command)
stdin = r.config.SudoPassword + "\n" + stdin
}
return r.client.RunCommandWithInput(command, []byte(stdin))
}

// applyCredentials fills connection fields that the operator did not set
Expand Down Expand Up @@ -318,7 +336,7 @@ func (r *sqlRun) resolveCredentials(source sqlsafe.CredSource) error {
if cmdErr != nil {
return r.fail("config", cmdErr)
}
res, execErr := r.client.RunCommandWithInput(cmd, nil)
res, execErr := r.runRemote(sqlsafe.RemoteCommand{Command: cmd})
if execErr != nil || res.ExitCode != 0 {
return r.fail("cred_source_failed", fmt.Errorf(
"failed to read credential source %s (remote status %d)",
Expand Down Expand Up @@ -546,13 +564,18 @@ func (r *sqlRun) failWithExit(kind string, res sshclient.ExecResult, failErr err
safeErr := failErr
if res.ExitCode >= 0 {
safeErr = fmt.Errorf("database operation failed during %s with status %d", r.phase, res.ExitCode)
if detail := firstNonEmptyLine(res.Stderr, res.Stdout); detail != "" {
safeErr = fmt.Errorf("%s: %s", safeErr.Error(), redactSensitiveText(detail))
}
}
r.recordAudit(res.ExitCode, kind, safeErr)
if r.config.JSONOutput {
result := r.baseResult()
result.ExitCode = res.ExitCode
result.ErrorKind = kind
result.Error = redactError(safeErr)
result.Stdout = res.Stdout
result.Stderr = res.Stderr
emitSQLJSON(result)
return ErrReported
}
Expand All @@ -565,6 +588,17 @@ func (r *sqlRun) failWithExit(kind string, res sshclient.ExecResult, failErr err
return failErr
}

func firstNonEmptyLine(chunks ...string) string {
for _, chunk := range chunks {
for _, line := range strings.Split(chunk, "\n") {
if s := strings.TrimSpace(line); s != "" {
return s
}
}
}
return ""
}

func (r *sqlRun) baseResult() sqlJSONResult {
result := sqlJSONResult{
Host: r.config.Host,
Expand All @@ -582,6 +616,7 @@ func (r *sqlRun) baseResult() sqlJSONResult {
}
result.CredSource = r.credSource
result.CredCache = r.credCache
result.Sudo = r.config.SQLUseSudo
if r.cls != nil {
result.Class = string(r.cls.Class)
result.Verb = r.cls.Verb
Expand Down
76 changes: 75 additions & 1 deletion internal/app/sql_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package app

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/talkincode/sshx/internal/sqlsafe"
"github.com/talkincode/sshx/internal/sshclient"
)

func TestParseArgs_SQLBasic(t *testing.T) {
Expand Down Expand Up @@ -40,8 +42,11 @@ func TestParseArgs_SQLBasic(t *testing.T) {
func TestParseArgs_SQLSQLite(t *testing.T) {
config := ParseArgs([]string{
"sshx", "sql", "-h=app1", "--engine=SQLite3", "--db-file=/var/lib/app/app.db",
"--json", "SELECT 1",
"--sudo", "--json", "SELECT 1",
})
if !config.SQLUseSudo {
t.Fatal("expected --sudo to set SQLUseSudo")
}
if config.SQLEngine != sqlsafe.EngineSQLite {
t.Fatalf("expected sqlite engine, got %s", config.SQLEngine)
}
Expand Down Expand Up @@ -279,6 +284,9 @@ func TestFillDryRunSQL(t *testing.T) {
if !strings.Contains(plan.SQL.ExecuteCommand, "sqlite3 -batch -bail /var/lib/app/app.db") {
t.Fatalf("execute preview must use sqlite3: %s", plan.SQL.ExecuteCommand)
}
if strings.Contains(plan.SQL.ExecuteCommand, "-readonly") {
t.Fatalf("sqlite DML must not use the non-portable -readonly flag: %s", plan.SQL.ExecuteCommand)
}
if strings.Contains(plan.SQL.ExecuteCommand, "psql") {
t.Fatal("sqlite plan must not assemble psql")
}
Expand Down Expand Up @@ -314,4 +322,70 @@ func TestFillDryRunSQL(t *testing.T) {
t.Fatalf("read must not mutate: %#v", plan)
}
})
t.Run("sqlite sudo wraps execute command", func(t *testing.T) {
config := ParseArgs([]string{
"sshx", "sql", "-h=app1", "--engine=sqlite", "--db-file=/var/lib/app/app.db",
"--sudo", "--dry-run", "SELECT 1",
})
plan := buildDryRunPlan(config)
if !plan.UsesSudo || plan.SQL == nil || !plan.SQL.UseSudo {
t.Fatalf("expected sudo in sql plan: %#v", plan)
}
if !plan.WouldReadSecret {
t.Fatal("sql --sudo must report would_read_secret")
}
if !strings.HasPrefix(plan.SQL.ExecuteCommand, "sudo -S -p '' sh -c ") {
t.Fatalf("execute preview must wrap sudo -S: %s", plan.SQL.ExecuteCommand)
}
if strings.Contains(plan.SQL.ExecuteCommand, "PGPASSWORD=") {
t.Fatal("sudo wrap must not embed secrets in argv")
}
})
t.Run("sqlite read uses portable file uri", func(t *testing.T) {
config := ParseArgs([]string{
"sshx", "sql", "-h=app1", "--engine=sqlite", "--db-file=/var/lib/app/app.db",
"--dry-run", "SELECT 1",
})
plan := buildDryRunPlan(config)
if plan.SQL == nil || plan.SQL.Class != string(sqlsafe.ClassRead) {
t.Fatalf("expected sqlite read plan: %#v", plan.SQL)
}
if !strings.Contains(plan.SQL.ExecuteCommand, "file:/var/lib/app/app.db?mode=ro") {
t.Fatalf("sqlite read must open a mode=ro URI: %s", plan.SQL.ExecuteCommand)
}
if strings.Contains(plan.SQL.ExecuteCommand, "-readonly") {
t.Fatalf("sqlite read must not use the non-portable -readonly flag: %s", plan.SQL.ExecuteCommand)
}
})
}

func TestSQLJSONFailureIncludesRemoteStderr(t *testing.T) {
config := ParseArgs([]string{
"sshx", "sql", "-h=app1", "--engine=sqlite", "--db-file=/tmp/x.db", "--json", "SELECT 1",
})
run := &sqlRun{config: config, start: time.Now(), phase: "execute"}

stdout := captureStdout(t, func() {
err := run.failWithExit("remote_exit", sshclient.ExecResult{
ExitCode: 1,
Stderr: "sqlite3: Error: unknown option: -readonly\nUse -help for a list of options.\n",
}, nil)
if err != ErrReported {
t.Fatalf("expected ErrReported, got %v", err)
}
})

var result sqlJSONResult
if err := json.Unmarshal(stdout, &result); err != nil {
t.Fatalf("invalid JSON %q: %v", stdout, err)
}
if result.ErrorKind != "remote_exit" || result.ExitCode != 1 {
t.Fatalf("unexpected failure envelope: %#v", result)
}
if !strings.Contains(result.Error, "unknown option: -readonly") {
t.Fatalf("error should include remote stderr: %q", result.Error)
}
if !strings.Contains(result.Stderr, "unknown option: -readonly") {
t.Fatalf("JSON stderr missing remote client output: %q", result.Stderr)
}
}
3 changes: 3 additions & 0 deletions internal/app/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ Guarded SQL Execution:
--allow-full-table Required for UPDATE/DELETE without a WHERE clause
--no-backup Skip pre-change backup (requires --force)
--backup-dir=PATH Remote backup directory (default: ~/.sshx/sql-backups)
--sudo Run sqlite3/psql via sudo -S (SSH user cannot open the file)
--force, -f Confirms DDL; destructive DDL also requires --no-backup

Safety pipeline for data changes: classify locally (fail-closed), gate by
Expand Down Expand Up @@ -297,6 +298,8 @@ Guarded SQL Execution:
--cred-cache=1h "SELECT count(*) FROM orders"
sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --json \
"UPDATE users SET active=0 WHERE id=42"
sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --sudo --json \
"UPDATE users SET active=0 WHERE id=42"

Guarded File Apply:
sshx apply -h=<host> --path=/abs/remote.conf --from=./local.conf [options]
Expand Down
11 changes: 11 additions & 0 deletions internal/sqlsafe/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,17 @@ func shellJoin(argv []string) string {
return strings.Join(parts, " ")
}

// WrapSudoStdin runs command under `sudo -S` with an empty prompt. The caller
// must prepend the sudo password plus a newline to stdin; sudo consumes that
// first line and the original command sees the rest. The password never enters
// argv. sh -c preserves pipelines, mkdir prefixes, and PGPASSWORD readers.
func WrapSudoStdin(command string) string {
if strings.TrimSpace(command) == "" {
return command
}
return "sudo -S -p '' sh -c " + shellQuote(command)
}

var safeArgRE = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`)

// ParseExplainRows extracts the top-level plan row estimate from
Expand Down
Loading
Loading