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
21 changes: 12 additions & 9 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ We take security seriously. The following versions of SSHX are currently support

| Version | Supported |
| ------- | ------------------ |
| 0.0.7 | :white_check_mark: |
| 0.0.6 | :white_check_mark: |
| 0.0.5 | :white_check_mark: |
| < 0.0.5 | :x: |
| 0.5.x | :white_check_mark: |
| 0.4.x | :white_check_mark: |
| < 0.4.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.

## Reporting a Vulnerability

Expand All @@ -25,10 +27,11 @@ If you discover a security vulnerability in SSHX, please report it by **one** of
- Click "Report a vulnerability"
- Fill in the details of the vulnerability

2. **Email**
- Send an email to the project maintainers
- Include detailed information about the vulnerability
- If possible, include steps to reproduce the issue
2. **GitHub private vulnerability reporting**
- If Security Advisories are unavailable, use GitHub's private vulnerability
reporting on this repository (Security tab → Report a vulnerability)
- Do not open a public issue for an unfixed vulnerability
- Do not send reports to an unpublished maintainer email

### What to Include

Expand Down Expand Up @@ -97,7 +100,7 @@ SSHX includes built-in validation to prevent dangerous commands (e.g., `rm -rf /

## Contact

For security-related questions or concerns that are not vulnerabilities, please open a regular issue on GitHub or contact the maintainers directly.
For security-related questions or concerns that are not vulnerabilities, please open a regular issue on GitHub. Do not use public issues to disclose unfixed vulnerabilities.

---

Expand Down
8 changes: 6 additions & 2 deletions docs/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ cat ./check.sh | sshx run --target=prod-web --script-stdin --json
- Dry-run and results expose payload SHA-256 and byte length, not raw script contents.
- Multi-target `--jsonl` streams `run_started`, per-target events, and `run_finished`.
- Multi-target exit codes: `0` all succeeded, `1` partial/failed/skipped/uncertain, `255` request-level failure.
- High-risk bypasses require explicit flags; `sshx run` also requires `--bypass-reason=`.
- High-risk bypasses require explicit flags; command mode and `sshx run` require `--bypass-reason=` with `--force` / `--no-safety-check`.
- Working-directory `.env` files are not loaded. Inherited `SSH_FORCE` /
`SSH_NO_SAFETY_CHECK` / host-key env switches do not authorize trust relaxation.

Expand Down Expand Up @@ -125,11 +125,15 @@ Use dry-run to verify host resolution, selected sudo key, safety status, and whe

## Timeouts

Always set timeouts for unattended workflows:
Always set timeouts for unattended workflows. `sshx run` defaults the command
timeout to 60s when `--timeout` / `SSH_TIMEOUT` are unset; compatibility
`sshx -h=...` command mode still has no command timeout unless you set one.
The SSH dial timeout is independent (30s).

```bash
sshx -h=prod-web --timeout=30s --json "systemctl is-active nginx"
sshx -h=prod-web --timeout=2m --json "sudo apt-get update"
sshx run --target=prod-web --json -- "uptime" # command timeout defaults to 60s
```

## Audit Events
Expand Down
52 changes: 51 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net"
"os"
"strings"
"time"

"github.com/talkincode/sshx/internal/execution"
Expand Down Expand Up @@ -72,6 +73,10 @@ func Run(args []string) (err error) {
if config.ArgumentError != "" {
return fmt.Errorf("%w: %s", execution.ErrConfig, config.ArgumentError)
}
applyCommandModeBypassReason(config, args)
if config.Mode == "run" && config.Timeout == 0 {
config.Timeout = 60 * time.Second
}
audit := newAuditRecorder(config)
defer func() {
if auditErr := audit.finish(config, err); auditErr != nil {
Expand All @@ -84,6 +89,12 @@ func Run(args []string) (err error) {
return HandleRun(config, audit)
}

if config.Mode == "ssh" {
if bypassErr := requireBypassReason(config); bypassErr != nil {
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config", bypassErr)
}
}

if config.DryRun {
return emitDryRunPlan(config)
}
Expand Down Expand Up @@ -146,7 +157,6 @@ func Run(args []string) (err error) {
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("--pty cannot be combined with --json (a PTY merges stderr into stdout)"))
}

// Reject dangerous commands before doing any network work so the
// rejection is deterministic and cheap, and reports a precise
// error_kind ("blocked") instead of being masked by a connect error.
Expand Down Expand Up @@ -290,6 +300,46 @@ func classifyError(err error) string {
return kind
}

// applyCommandModeBypassReason lifts --bypass-reason= from argv for compatibility
// command mode, where ParseArgs historically treated the flag as the start of the
// remote command. It also strips a leftover flag token from Config.Command so it
// is never executed remotely.
func applyCommandModeBypassReason(config *sshclient.Config, args []string) {
if config == nil || config.Mode != "ssh" {
return
}
for _, arg := range args {
if arg == "--" {
return
}
if strings.HasPrefix(arg, "--bypass-reason=") {
config.BypassReason = strings.SplitN(arg, "=", 2)[1]
if config.Command == arg {
config.Command = ""
} else if strings.HasPrefix(config.Command, arg+" ") {
config.Command = strings.TrimPrefix(config.Command, arg+" ")
}
return
}
}
}

// requireBypassReason enforces the run-mode rule on command mode: --force and
// --no-safety-check are explicit break-glass switches and must carry a
// non-empty --bypass-reason for audit. Skill/SQL --force keep their own
// semantics and are not gated here.
func requireBypassReason(config *sshclient.Config) error {
if config == nil {
return nil
}
if config.Force || !config.SafetyCheck {
if strings.TrimSpace(config.BypassReason) == "" {
return fmt.Errorf("safety bypass requires a non-empty --bypass-reason")
}
}
return nil
}

// isIPAddress checks if a string is a valid IP address
func isIPAddress(host string) bool {
return net.ParseIP(host) != nil
Expand Down
2 changes: 2 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ func ParseArgs(args []string) *sshclient.Config {
config.UseKeyAuth = true
case arg == "--force", arg == "-f":
config.Force = true
case strings.HasPrefix(arg, "--bypass-reason="):
config.BypassReason = strings.SplitN(arg, "=", 2)[1]
case arg == "--accept-unknown-host":
config.AcceptUnknownHost = true
case arg == "--insecure-hostkey":
Expand Down
28 changes: 28 additions & 0 deletions internal/app/guardrails_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package app

import (
"strings"
"testing"
)

func TestApplyCommandModeBypassReason(t *testing.T) {
args := []string{"sshx", "-h=host", "--force", "--bypass-reason=maintenance window", "sudo reboot"}
config := ParseArgs(args)
if config.BypassReason != "maintenance window" {
t.Fatalf("ParseArgs BypassReason=%q", config.BypassReason)
}
applyCommandModeBypassReason(config, args)
if config.BypassReason != "maintenance window" {
t.Fatalf("BypassReason=%q", config.BypassReason)
}
if config.Command != "sudo reboot" {
t.Fatalf("Command=%q, want sudo reboot without leftover flag", config.Command)
}
}

func TestRequireBypassReason(t *testing.T) {
err := requireBypassReason(ParseArgs([]string{"sshx", "-h=host", "--force", "reboot"}))
if err == nil || !strings.Contains(err.Error(), "--bypass-reason") {
t.Fatalf("expected bypass-reason error, got %v", err)
}
}
2 changes: 2 additions & 0 deletions internal/app/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ Audit Trail:
Safety Options:
-f, --force Force execution, bypass safety checks (use with caution!)
--no-safety-check Disable safety checks completely (not recommended)
--bypass-reason=TEXT Required with --force / --no-safety-check in command
mode and sshx run (recorded in dry-run, result, audit)

Safety checks protect against:
- Destructive operations (rm -rf /, mkfs, dd)
Expand Down
1 change: 1 addition & 0 deletions internal/app/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func TestPrintUsage(t *testing.T) {
"--audit-output",
"--force",
"--no-safety-check",
"--bypass-reason",
"sshx inspect",
"sshx plugin create",
"sshx skill install",
Expand Down
26 changes: 22 additions & 4 deletions internal/sqlsafe/credsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ func ParseCredSource(spec string) (CredSource, error) {
}
return CredSource{Kind: "docker", Container: rest}, nil
case "env-file":
if strings.ContainsAny(rest, "\n\r") {
return CredSource{}, fmt.Errorf("invalid env file path in --db-cred-from")
if err := validateEnvFilePath(rest); err != nil {
return CredSource{}, err
}
return CredSource{Kind: "env-file", Path: rest}, nil
default:
Expand Down Expand Up @@ -80,8 +80,8 @@ func (s CredSource) ExtractionCommand() (string, error) {
}
return "docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' " + s.Container, nil
case "env-file":
if s.Path == "" || strings.ContainsAny(s.Path, "\n\r") {
return "", fmt.Errorf("invalid env file path")
if err := validateEnvFilePath(s.Path); err != nil {
return "", err
}
return "cat " + maybeQuote(s.Path), nil
default:
Expand Down Expand Up @@ -206,3 +206,21 @@ func ValidateContainerName(name string) error {
}
return nil
}

// validateEnvFilePath requires an absolute remote path and rejects parent
// directory segments so env-file: cannot be used to walk the remote tree via
// relative or .. paths. Newlines were already rejected; this tightens the rest.
func validateEnvFilePath(path string) error {
if path == "" || strings.ContainsAny(path, "\x00\r\n") {
return fmt.Errorf("invalid env file path in --db-cred-from")
}
if !isAbsoluteSQLitePath(path) {
return fmt.Errorf("env-file path in --db-cred-from must be absolute")
}
for _, seg := range splitPathSegments(path) {
if seg == ".." {
return fmt.Errorf("env-file path in --db-cred-from must not contain .. segments")
}
}
return nil
}
2 changes: 1 addition & 1 deletion internal/sqlsafe/credsource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestParseCredSource(t *testing.T) {
assert.Equal(t, CredSource{Kind: "env-file", Path: "/opt/app/.env"}, s)
assert.Equal(t, "env-file:/opt/app/.env", s.String())

for _, bad := range []string{"", "docker:", "docker:bad name", "docker:-leading", "vault:x", "env-file:", "plain"} {
for _, bad := range []string{"", "docker:", "docker:bad name", "docker:-leading", "vault:x", "env-file:", "plain", "env-file:relative.env", "env-file:./.env", "env-file:opt/app/.env", "env-file:/opt/../etc/passwd", "env-file:/tmp/../.env"} {
_, err := ParseCredSource(bad)
assert.Error(t, err, "spec %q must be rejected", bad)
}
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/cli_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func TestCLISafetyBlockPreventsConnectionAndForceIsExplicit(t *testing.T) {
assertSSHXFailure(t, blocked, "blocked")
assert.Equal(t, connectionsBefore, server.connections.Load(), "blocked commands must not touch the network")

forced := runSSHX(t, home, append(append([]string{}, base...), "--force", "--accept-unknown-host", "rm -rf /"), map[string]string{
forced := runSSHX(t, home, append(append([]string{}, base...), "--force", "--bypass-reason=e2e destructive command", "--accept-unknown-host", "rm -rf /"), map[string]string{
"SSH_PASSWORD": operatorPassword,
})
require.Equal(t, 0, forced.exitCode, forced.stderr)
Expand Down
Loading